@robota-sdk/agent-transport-webrtc 3.0.0-beta.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +361 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +361 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +88 -0
- package/src/__tests__/admission-secure-by-default.test.ts +61 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/cve-2024-29415-reachability.test.ts +80 -0
- package/src/__tests__/dtls-fingerprint-binding.test.ts +60 -0
- package/src/__tests__/handoff-grant-gate.test.ts +401 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +213 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/negotiated-certificate.test.ts +80 -0
- package/src/__tests__/operator-approval-gate.test.ts +306 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +152 -0
- package/src/__tests__/pairing-e2e.test.ts +238 -0
- package/src/__tests__/pairing-gate-e3.test.ts +192 -0
- package/src/__tests__/pairing-gate-e4.test.ts +108 -0
- package/src/__tests__/pairing-gate.test.ts +276 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +229 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/werift-loader.test.ts +18 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +21 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/negotiated-certificate.ts +80 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +79 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +129 -0
- package/src/pairing-gate.ts +376 -0
- package/src/session-attachment.ts +88 -0
- package/src/signaling.ts +62 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/webrtc-delivery-lifecycle.ts +51 -0
- package/src/webrtc-transport-options.ts +84 -0
- package/src/webrtc-transport.ts +328 -0
- package/src/werift-loader.ts +40 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { IProtocolSession, ISessionMessageHandlerOptions, SessionResumeBridge, createSessionMessageHandler } from "@robota-sdk/agent-transport";
|
|
2
|
+
import { IPairingResult, startPairingHandshake } from "@robota-sdk/agent-remote-pairing";
|
|
3
|
+
import { IConfigurableTransport } from "@robota-sdk/agent-interface-transport";
|
|
4
|
+
import { IPeerAdmission } from "@robota-sdk/agent-interface-session-mobility";
|
|
5
|
+
import { RTCPeerConnection } from "werift";
|
|
6
|
+
//#region src/handoff-grant-gate.d.ts
|
|
7
|
+
/** The frame a source device presents to show it holds a grant for this transfer. */
|
|
8
|
+
interface IHandoffGrantFrame {
|
|
9
|
+
readonly t: 'handoff-grant';
|
|
10
|
+
/**
|
|
11
|
+
* The signed grant, carried opaquely.
|
|
12
|
+
*
|
|
13
|
+
* `unknown` on purpose: this package cannot import the grant type without importing the package
|
|
14
|
+
* that owns the crypto, and typing it structurally here would create a second declaration of a
|
|
15
|
+
* signed object's shape — which is how a field ends up checked in one copy and not the other.
|
|
16
|
+
* The verifier owns the type; this gate owns the envelope.
|
|
17
|
+
*/
|
|
18
|
+
readonly grant: unknown;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The cross-device authorization this gate requires before exposing the session.
|
|
22
|
+
*
|
|
23
|
+
* Absent → the gate behaves exactly as before. Requiring a grant is opt-in because an ordinary
|
|
24
|
+
* remote peer has no hand-off to authorize, and a gate that demanded one unconditionally would
|
|
25
|
+
* refuse every legitimate remote session.
|
|
26
|
+
*/
|
|
27
|
+
interface IHandoffGrantProof {
|
|
28
|
+
/**
|
|
29
|
+
* Judge a presented grant. Owned by the verifier — this package asks and does not decide.
|
|
30
|
+
*
|
|
31
|
+
* Async because verifying a signature is: `verifyHandoffGrant` returns a promise, and a
|
|
32
|
+
* synchronous seam here would force the owner to block or to pre-verify, and pre-verifying means
|
|
33
|
+
* deciding before the channel that the grant is bound to even exists.
|
|
34
|
+
*
|
|
35
|
+
* Returns the admission the consumer receives, so the trust level travels as a value.
|
|
36
|
+
*/
|
|
37
|
+
readonly verify: (grant: unknown) => Promise<IPeerAdmission>;
|
|
38
|
+
/**
|
|
39
|
+
* Ask the person at THIS machine whether to accept the transfer. Absent → no consent is required.
|
|
40
|
+
*
|
|
41
|
+
* Called ONLY on a verdict the verifier admitted, and given that verdict, for two reasons that
|
|
42
|
+
* both matter:
|
|
43
|
+
*
|
|
44
|
+
* The prompt can then name a PROVEN origin. Asking before verification would let anyone who can
|
|
45
|
+
* open a channel raise a dialog on someone's machine claiming to be any device they like, which
|
|
46
|
+
* turns the consent step into an attack surface instead of a control.
|
|
47
|
+
*
|
|
48
|
+
* And consent is not cryptographic policy, so it does not belong inside `verify`. A verifier that
|
|
49
|
+
* also asked a human would make "is this grant valid" and "does this person want it" one answer,
|
|
50
|
+
* and the first is the one that must be decidable without a person present.
|
|
51
|
+
*
|
|
52
|
+
* Returning false — or throwing, or having no renderer to ask — refuses. Denial fails CLOSED.
|
|
53
|
+
*/
|
|
54
|
+
readonly consent?: (admission: IPeerAdmission) => Promise<boolean>;
|
|
55
|
+
/**
|
|
56
|
+
* Fired on the outcome, admitted or not.
|
|
57
|
+
*
|
|
58
|
+
* On BOTH, deliberately: a consumer told only about successes cannot distinguish "no hand-off was
|
|
59
|
+
* offered" from "a hand-off was refused", and those call for different operator responses — the
|
|
60
|
+
* second is the one that belongs in front of a person.
|
|
61
|
+
*/
|
|
62
|
+
readonly onAdmission?: (admission: IPeerAdmission) => void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build the frame a source device presents on the channel.
|
|
66
|
+
*
|
|
67
|
+
* Lives beside the judge that reads it, so the two cannot drift: a sender that hand-built the object
|
|
68
|
+
* would keep compiling after the frame gains a field, and the failure would surface as a refusal on
|
|
69
|
+
* the far side with no hint that the SENDER is the stale half.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately not a "send" — it returns the frame and leaves transmission to whoever owns the
|
|
72
|
+
* channel, for the same reason `localProofFrame` does.
|
|
73
|
+
*/
|
|
74
|
+
declare function handoffGrantFrame(grant: unknown): IHandoffGrantFrame;
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/local-peer-proof.d.ts
|
|
77
|
+
/** The frame a local peer presents to show it reached the guarded rendezvous. */
|
|
78
|
+
interface ILocalProofFrame {
|
|
79
|
+
readonly t: 'local-proof';
|
|
80
|
+
readonly nonce: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The local-environment proof this gate requires before exposing the session.
|
|
84
|
+
*
|
|
85
|
+
* Absent → the gate behaves exactly as before. Requiring the proof is opt-in because a remote peer
|
|
86
|
+
* over WebRTC has no rendezvous to have reached, and a gate that demanded one unconditionally would
|
|
87
|
+
* refuse every legitimate remote session.
|
|
88
|
+
*/
|
|
89
|
+
interface ILocalPeerProof {
|
|
90
|
+
/**
|
|
91
|
+
* Redeem a presented nonce. Owned by the ledger — this package asks and does not decide.
|
|
92
|
+
*
|
|
93
|
+
* Returns the admission the consumer receives, so the trust level travels as a value rather than
|
|
94
|
+
* being re-derived from a boolean by whoever reads it next.
|
|
95
|
+
*/
|
|
96
|
+
readonly redeem: (nonce: string) => IPeerAdmission;
|
|
97
|
+
/**
|
|
98
|
+
* Fired on the outcome, admitted or not.
|
|
99
|
+
*
|
|
100
|
+
* On BOTH, deliberately: a consumer told only about successes cannot distinguish "no local peer
|
|
101
|
+
* connected" from "a local peer was refused", and those call for different operator responses.
|
|
102
|
+
*/
|
|
103
|
+
readonly onAdmission?: (admission: IPeerAdmission) => void;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Build the frame a local peer presents on the channel.
|
|
107
|
+
*
|
|
108
|
+
* Lives beside the judge that reads it, so the two cannot drift: a sender that hand-built the object
|
|
109
|
+
* would keep compiling after the frame gains a field, and the failure would surface as a refusal on
|
|
110
|
+
* the far side with no hint that the SENDER is the stale half.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately not a "send" — it returns the frame and leaves transmission to whoever owns the
|
|
113
|
+
* channel. A helper that both built and sent would need a channel to be testable, and would put the
|
|
114
|
+
* frame's shape and the carrier's lifecycle in one place.
|
|
115
|
+
*/
|
|
116
|
+
declare function localProofFrame(nonce: string): ILocalProofFrame;
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/pairing-gate-options.d.ts
|
|
119
|
+
/** E3 host reconnect/enrollment config. When present, the gate runs reactive (first-frame) mode detection. */
|
|
120
|
+
interface IHostReconnectConfig {
|
|
121
|
+
readonly hostIdentityId: string;
|
|
122
|
+
/** base64url SPKI advertised to a device at first-pair enrollment. */
|
|
123
|
+
readonly hostPublicSpki: string;
|
|
124
|
+
/** The host identity private key (signs reconnect challenges). */
|
|
125
|
+
readonly hostPrivateKey: CryptoKey;
|
|
126
|
+
/** Resolve a pinned device public key by id (undefined → unknown/revoked → fail closed). */
|
|
127
|
+
readonly resolveDevicePublicKey: (deviceId: string) => Promise<CryptoKey | undefined>;
|
|
128
|
+
/** Pin a device's public key on first-pair enrollment (deviceId, base64url SPKI). */
|
|
129
|
+
readonly onEnroll: (deviceId: string, deviceSpki: string) => void;
|
|
130
|
+
}
|
|
131
|
+
/** What the receiving operator is told about a connection that would drive the session. */
|
|
132
|
+
interface IConnectionApprovalContext {
|
|
133
|
+
/** The device the handshake proved, when the carrier has a stable id for it. */
|
|
134
|
+
readonly deviceId?: string;
|
|
135
|
+
/** A trusted device coming back, rather than one pairing now. */
|
|
136
|
+
readonly viaReconnect: boolean;
|
|
137
|
+
/** Aborted when the connection goes away before an answer: withdraw the question. */
|
|
138
|
+
readonly signal: AbortSignal;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The receiving operator's say over one connection. `approve` resolves `true` only on the operator's
|
|
142
|
+
* explicit yes; `false` or a rejection refuses the connection.
|
|
143
|
+
*/
|
|
144
|
+
interface IConnectionApproval {
|
|
145
|
+
approve(context: IConnectionApprovalContext): Promise<boolean>;
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/signaling.d.ts
|
|
149
|
+
/**
|
|
150
|
+
* Signaling port (REMOTE-002). The WebRTC transport is decoupled from any concrete signaling server via this
|
|
151
|
+
* port: it exchanges opaque SDP/ICE blobs by rendezvous id. The port carries **only** SDP offers/answers + ICE
|
|
152
|
+
* candidates — never session content — so a signaling server (or the in-memory test pair) is content-blind.
|
|
153
|
+
*/
|
|
154
|
+
/** The kinds of signal a peer exchanges through the relay. */
|
|
155
|
+
type TSignalKind = 'offer' | 'answer' | 'ice';
|
|
156
|
+
/** An opaque signaling message relayed by rendezvous id. `data` is an SDP description or an ICE candidate. */
|
|
157
|
+
interface ISignalMessage {
|
|
158
|
+
readonly kind: TSignalKind;
|
|
159
|
+
/** Opaque payload (serialized SDP or ICE candidate). The signaling layer never inspects it. */
|
|
160
|
+
readonly data: unknown;
|
|
161
|
+
}
|
|
162
|
+
/** Port a WebRTC peer uses to reach its counterpart at a rendezvous id. */
|
|
163
|
+
interface ISignalingClient {
|
|
164
|
+
/** Send a signal to the counterpart at the rendezvous. */
|
|
165
|
+
send(message: ISignalMessage): void;
|
|
166
|
+
/** Subscribe to signals from the counterpart. Returns an unsubscribe function. */
|
|
167
|
+
onSignal(handler: (message: ISignalMessage) => void): () => void;
|
|
168
|
+
/** Tear down the signaling channel. */
|
|
169
|
+
close(): void;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* An in-process pair of {@link ISignalingClient}s wired directly to each other — for tests + loopback only
|
|
173
|
+
* (no server, no network). Each side's `send` is delivered to the other side's handlers on a microtask.
|
|
174
|
+
*/
|
|
175
|
+
declare function createInMemorySignalingPair(): [ISignalingClient, ISignalingClient];
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/werift-loader.d.ts
|
|
178
|
+
/** The subset of the `werift` module surface this transport constructs. */
|
|
179
|
+
interface IWeriftModule {
|
|
180
|
+
RTCPeerConnection: new (configuration?: {
|
|
181
|
+
iceServers?: {
|
|
182
|
+
urls: string;
|
|
183
|
+
username?: string;
|
|
184
|
+
credential?: string;
|
|
185
|
+
}[];
|
|
186
|
+
iceTransportPolicy?: 'all' | 'relay';
|
|
187
|
+
}) => RTCPeerConnection;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Lazily load the optional `werift` (pure-TypeScript WebRTC) peer dependency (REMOTE-002). `werift` is kept
|
|
191
|
+
* OUT of the transport's runtime dependency graph (peer + optional) so this package is importable without it;
|
|
192
|
+
* absence surfaces an **explicit "WebRTC transport unavailable" error at point-of-use** (a throw, mirroring
|
|
193
|
+
* `agent-cli`'s `loadReplayProvider`) — never a silent no-op or degraded path (no-fallback rule). The
|
|
194
|
+
* werift-vs-native choice is a recorded design decision, not a runtime fallback.
|
|
195
|
+
*/
|
|
196
|
+
/** Resolve a module by id. The default resolves the real `werift`; tests inject a throwing resolver. */
|
|
197
|
+
type TModuleResolver = (id: string) => unknown;
|
|
198
|
+
declare function loadWerift(resolve?: TModuleResolver): IWeriftModule;
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/webrtc-transport-options.d.ts
|
|
201
|
+
/**
|
|
202
|
+
* A single ICE (STUN/TURN) server for the HOST (werift) transport (REMOTE-010). `urls` is a SINGLE string with a
|
|
203
|
+
* `turn:`/`turns:`/`stun:`/`stuns:` scheme — werift's ICE gatherer (`parseIceServers`) consumes only a single-string
|
|
204
|
+
* url and silently drops array `urls`, so the host reader (`agent-cli` `parseIceServers`) must narrow to this shape
|
|
205
|
+
* and reject what werift would drop (fail-closed). (The browser peer uses the native DOM `RTCIceServer`, which does
|
|
206
|
+
* support array urls / `turns:` — a separate, wider validator.) Kept a plain interface (no DOM dependency here).
|
|
207
|
+
*/
|
|
208
|
+
interface IIceServer {
|
|
209
|
+
readonly urls: string;
|
|
210
|
+
readonly username?: string;
|
|
211
|
+
readonly credential?: string;
|
|
212
|
+
}
|
|
213
|
+
/** Construction options for {@link WebRtcTransport}. The signaling client is injected (Stage A: no settings). */
|
|
214
|
+
interface IWebRtcTransportOptions {
|
|
215
|
+
/** Host-owned usage read models, available only after admission. */
|
|
216
|
+
readonly personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
217
|
+
readonly usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
218
|
+
readonly storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
219
|
+
/** Signaling port used to exchange SDP/ICE with the remote peer by rendezvous id. */
|
|
220
|
+
readonly signaling: ISignalingClient;
|
|
221
|
+
/** Optional ICE servers (STUN/TURN). Omitted → host-candidate/loopback only. */
|
|
222
|
+
readonly iceServers?: readonly IIceServer[];
|
|
223
|
+
/**
|
|
224
|
+
* REMOTE-004 defense-in-depth: when true, restrict ICE to **relay (TURN) candidates only**, so
|
|
225
|
+
* host/server-reflexive candidates — and the local-interface gathering that touches the (unreachable, but
|
|
226
|
+
* belt-and-braces) `ip` code path — are never used. Requires a TURN server in `iceServers`. Mapped to werift's
|
|
227
|
+
* `iceTransportPolicy: 'relay'` (REMOTE-010) — werift IGNORES a top-level `forceTurn`, so it must NOT be passed.
|
|
228
|
+
*/
|
|
229
|
+
readonly forceTurn?: boolean;
|
|
230
|
+
/**
|
|
231
|
+
* REMOTE-008 pairing secret. When set, the data channel is **pairing-gated**: it carries only pairing frames
|
|
232
|
+
* until the directional-HMAC handshake accepts (channel-bound to the DTLS fingerprints), and only THEN is the
|
|
233
|
+
* session exposed — fail closed on mismatch/timeout. When omitted (Stage-A loopback / tests), the channel is
|
|
234
|
+
* exposed immediately with no pairing (unchanged behavior).
|
|
235
|
+
*/
|
|
236
|
+
readonly secret?: string;
|
|
237
|
+
/**
|
|
238
|
+
* SEC-008: run with NO pairing gate. Requires `openReason`.
|
|
239
|
+
*
|
|
240
|
+
* Omitting `secret` used to mean this implicitly, which is how a remote peer reached the session
|
|
241
|
+
* because a field was left unset. It is still a legitimate mode — loopback, tests — but it is now
|
|
242
|
+
* a thing the host says rather than a thing that happens.
|
|
243
|
+
*/
|
|
244
|
+
readonly open?: boolean;
|
|
245
|
+
/** SEC-008: why running with no pairing gate is correct here. Required when `open` is true. */
|
|
246
|
+
readonly openReason?: string;
|
|
247
|
+
/** REMOTE-008: fired when pairing accepts + the session is exposed (host lifecycle → status 'paired'). Carries the first-pair result (E4 uses its sessionKey). */
|
|
248
|
+
readonly onPaired?: (result?: IPairingResult) => void;
|
|
249
|
+
/** REMOTE-008: fired when pairing rejects/times out (host lifecycle → teardown; the channel is already closed). */
|
|
250
|
+
readonly onPairingFailed?: () => void;
|
|
251
|
+
/** REMOTE-012 E3: host reconnect/enrollment config. When set, the gate admits first-pair (with enrollment) OR a pinned-device reconnect. */
|
|
252
|
+
readonly reconnect?: IHostReconnectConfig;
|
|
253
|
+
/** SEC-010 (#1810): require a guarded-rendezvous nonce before the session is exposed. Absent → unchanged. */
|
|
254
|
+
readonly localPeer?: ILocalPeerProof;
|
|
255
|
+
/** Ask the receiving operator before each connection reaches the session. Absent → unchanged. */
|
|
256
|
+
readonly connectionApproval?: IConnectionApproval;
|
|
257
|
+
/** REMOTE-013 E4: a session-scoped resume bridge (owned by the controller across reconnects). Passed to the gate so the paired session flows through it (seq/buffer) and survives channel drops. */
|
|
258
|
+
readonly resumeBridge?: SessionResumeBridge;
|
|
259
|
+
/** REMOTE-013 E4: fired when a PAIRED data channel drops (so the controller can run the reconnect loop). Not fired for a pre-accept failure (that is `onPairingFailed`). */
|
|
260
|
+
readonly onDropped?: () => void;
|
|
261
|
+
/** Observe an outbound session-event delivery failure before the carrier drops. */
|
|
262
|
+
readonly onDeliveryError?: (error: Error, event: string) => void;
|
|
263
|
+
/** Test seam: inject the werift module (defaults to the real lazy loader). */
|
|
264
|
+
readonly loadWerift?: () => IWeriftModule;
|
|
265
|
+
}
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/webrtc-transport.d.ts
|
|
268
|
+
/**
|
|
269
|
+
* WebRTC P2P transport (REMOTE-001/002): carries an `IProtocolSession` over an `RTCDataChannel` using the
|
|
270
|
+
* SAME transport-neutral session bridge as the WebSocket transport (`createSessionMessageHandler` from
|
|
271
|
+
* `@robota-sdk/agent-transport`). The host is the offerer: it creates the data channel + offer, and on
|
|
272
|
+
* data-channel open wires the handler. **Stage A: `defaultEnabled: false`, no pairing/auth** — the signaling
|
|
273
|
+
* client is injected and can be an in-memory loopback for tests.
|
|
274
|
+
*/
|
|
275
|
+
declare class WebRtcTransport implements IConfigurableTransport<IProtocolSession> {
|
|
276
|
+
private readonly options;
|
|
277
|
+
readonly name = "webrtc";
|
|
278
|
+
readonly lifecycle: Readonly<{
|
|
279
|
+
kind: "service";
|
|
280
|
+
}>;
|
|
281
|
+
readonly defaultEnabled = false;
|
|
282
|
+
readonly optionsSchema: {};
|
|
283
|
+
private session?;
|
|
284
|
+
private peer?;
|
|
285
|
+
private unsubscribeSignal?;
|
|
286
|
+
private cleanupHandler?;
|
|
287
|
+
/** Invalidates pending async startup work and scopes pairing/drop state to one start generation. */
|
|
288
|
+
private generation;
|
|
289
|
+
/** Local DTLS fingerprint captured for pairing channel binding. */
|
|
290
|
+
private localFingerprint?;
|
|
291
|
+
/** Pairing gate for the current channel. */
|
|
292
|
+
private pairingGate?;
|
|
293
|
+
/** Whether this start generation has taken its one answer; any later answer is ignored. */
|
|
294
|
+
private answered;
|
|
295
|
+
/** Pre-gate channel frames, replayed into the gate once it exists. Bounded. */
|
|
296
|
+
private pendingFrames;
|
|
297
|
+
private stopAwaitingCertificate?;
|
|
298
|
+
private readonly deliveryLifecycle;
|
|
299
|
+
constructor(options: IWebRtcTransportOptions);
|
|
300
|
+
validateOptions(): boolean;
|
|
301
|
+
attach(session: IProtocolSession): void;
|
|
302
|
+
private createPeer;
|
|
303
|
+
private wireSignaling;
|
|
304
|
+
private requireCurrentPeer;
|
|
305
|
+
start(): Promise<void>;
|
|
306
|
+
/**
|
|
307
|
+
* With a pairing secret, the answer must advertise exactly one DTLS fingerprint; its algorithm is the one the
|
|
308
|
+
* verified certificate is hashed with. A refused answer fails pairing and closes the channel.
|
|
309
|
+
*/
|
|
310
|
+
private remoteFingerprintAlgorithm;
|
|
311
|
+
private failPairing;
|
|
312
|
+
/** Build the pairing gate once the DTLS layer has verified the remote certificate. */
|
|
313
|
+
private awaitVerifiedCertificate;
|
|
314
|
+
private startPairing;
|
|
315
|
+
private wireChannel;
|
|
316
|
+
stop(): Promise<void>;
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/ws-signaling-client.d.ts
|
|
320
|
+
/** A minimal WebSocket-like surface — so tests can inject a fake without a real socket. */
|
|
321
|
+
interface IWebSocketLike {
|
|
322
|
+
send(data: string): void;
|
|
323
|
+
close(): void;
|
|
324
|
+
readyState: number;
|
|
325
|
+
on(event: 'open' | 'message' | 'error' | 'close', handler: (arg: unknown) => void): void;
|
|
326
|
+
}
|
|
327
|
+
interface IWsSignalingClientOptions {
|
|
328
|
+
/** Relay URL, e.g. `ws://127.0.0.1:1234`. */
|
|
329
|
+
readonly url: string;
|
|
330
|
+
/** Rendezvous id to join. */
|
|
331
|
+
readonly rendezvous: string;
|
|
332
|
+
/**
|
|
333
|
+
* Explicit error sink. Called on a relay `error` frame, a socket error, or a close-before-join — the signaling
|
|
334
|
+
* channel cannot function and the caller must know (no silent degrade).
|
|
335
|
+
*/
|
|
336
|
+
readonly onError?: (error: Error) => void;
|
|
337
|
+
/** Called once the relay confirms the rendezvous `join` (the `joined` frame). */
|
|
338
|
+
readonly onReady?: () => void;
|
|
339
|
+
/** Injectable socket factory (defaults to a real `ws` WebSocket) — for tests. */
|
|
340
|
+
readonly createSocket?: (url: string) => IWebSocketLike;
|
|
341
|
+
}
|
|
342
|
+
declare class WsSignalingClient implements ISignalingClient {
|
|
343
|
+
private readonly options;
|
|
344
|
+
private readonly socket;
|
|
345
|
+
private readonly handlers;
|
|
346
|
+
private readonly outbox;
|
|
347
|
+
private joined;
|
|
348
|
+
private closed;
|
|
349
|
+
constructor(options: IWsSignalingClientOptions);
|
|
350
|
+
private handleOpen;
|
|
351
|
+
private handleMessage;
|
|
352
|
+
private handleClose;
|
|
353
|
+
private fail;
|
|
354
|
+
private writeSignal;
|
|
355
|
+
send(message: ISignalMessage): void;
|
|
356
|
+
onSignal(handler: (message: ISignalMessage) => void): () => void;
|
|
357
|
+
close(): void;
|
|
358
|
+
}
|
|
359
|
+
//#endregion
|
|
360
|
+
export { type IConnectionApproval, type IConnectionApprovalContext, type IHandoffGrantFrame, type IHandoffGrantProof, type IHostReconnectConfig, type IIceServer, type ILocalPeerProof, type ILocalProofFrame, type ISignalMessage, type ISignalingClient, type IWebRtcTransportOptions, type IWebSocketLike, type IWeriftModule, type IWsSignalingClientOptions, type TModuleResolver, type TSignalKind, WebRtcTransport, WsSignalingClient, createInMemorySignalingPair, handoffGrantFrame, loadWerift, localProofFrame };
|
|
361
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../../src/handoff-grant-gate.ts","../../src/local-peer-proof.ts","../../src/pairing-gate-options.ts","../../src/signaling.ts","../../src/werift-loader.ts","../../src/webrtc-transport-options.ts","../../src/webrtc-transport.ts","../../src/ws-signaling-client.ts"],"mappings":";;;;;;;UA6CiB;WACN;;;;;;;;;WASA;;;;;;;;;UAUM;;;;;;;;;;WAUN,SAAS,mBAAmB,QAAQ;;;;;;;;;;;;;;;;;WAiBpC,WAAW,WAAW,mBAAmB;;;;;;;;WAQzC,eAAe,WAAW;;;;;;;;;;;;iBAmFrB,kBAAkB,iBAAiB;;;;UC9IlC;WACN;WACA;;;;;;;;;UAUM;;;;;;;WAON,SAAS,kBAAkB;;;;;;;WAO3B,eAAe,WAAW;;;;;;;;;;;;;iBAiErB,gBAAgB,gBAAgB;;;;UC1F/B;WACN;;WAEA;;WAEA,gBAAgB;;WAEhB,yBAAyB,qBAAqB,QAAQ;;WAEtD,WAAW,kBAAkB;;;UAIvB;;WAEN;;WAEA;;WAEA,QAAQ;;;;;;UAOF;EACf,QAAQ,SAAS,6BAA6B;;;;;;;;;;KC9DpC;;UAGK;WACN,MAAM;;WAEN;;;UAIM;;EAEf,KAAK,SAAS;;EAEd,SAAS,UAAU,SAAS;;EAE5B;;;;;;iBAOc,gCAAgC,kBAAkB;;;;UCzBjD;EACf,wBAAwB;IAItB;MAAe;MAAc;MAAmB;;IAChD;QACI;;;;;;;;;;KAWI,mBAAmB;iBAQf,WAAW,UAAS,kBAAoC;;;;;;;;;;UCNvD;WACN;WACA;WACA;;;UAIM;;WAEN,wBAAwB;WACxB,gBAAgB;WAChB,6BAA6B;;WAE7B,WAAW;;WAEX,sBAAsB;;;;;;;WAOtB;;;;;;;WAOA;;;;;;;;WAQA;;WAEA;;WAEA,YAAY,SAAS;;WAErB;;WAEA,YAAY;;WAEZ,YAAY;;WAEZ,qBAAqB;;WAErB,eAAe;;WAEf;;WAEA,mBAAmB,OAAO,OAAO;;WAEjC,mBAAmB;;;;;;;;;;;cCrDjB,2BAA2B,uBAAuB;mBAuBzB;WAtBpB;WACA,WAAS;;;WACT;WACA;UAER;UACA;UACA;UACA;;UAEA;;UAEA;;UAEA;;UAEA;;UAEA;UACA;mBACS;EAEmB,YAAA,SAAS;EA+BtC;EAIA,OAAO,SAAS;UAGf;UAYA;UAmCM;EAMD,SAAS;;;;;UA+Bd;UAYA;;UAUA;UAoBA;UA0CA;EAkDK,QAAQ;;;;;UCnSN;EACf,KAAK;EACL;EACA;EACA,GAAG,+CAA+C,UAAU;;UAG7C;;WAEN;;WAEA;;;;;WAKA,WAAW,OAAO;;WAElB;;WAEA,gBAAgB,gBAAgB;;cAU9B,6BAA6B;mBAOJ;mBANnB;mBACA;mBACA;UACT;UACA;EAE4B,YAAA,SAAS;UAWrC;UAOA;UA4BA;UAOA;UAIA;EAID,KAAK,SAAS;EASd,SAAS,UAAU,SAAS;EAQ5B"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{createRequire as e}from"node:module";import{createOutboundDelivery as t,createSessionMessageHandler as n}from"@robota-sdk/agent-transport";import{resolveAdmission as r}from"@robota-sdk/agent-transport/node";import{decodeEnrollFrame as i,decodePairingFrame as a,decodeReconnectFrame as o,deriveIdentityId as s,extractDtlsFingerprint as c,extractDtlsFingerprintAttribute as l,importPublicKey as u,startHostReconnect as d,startPairingHandshake as f}from"@robota-sdk/agent-remote-pairing";import{createHash as p}from"node:crypto";import m from"ws";function h(e,n){return t(t=>e.send(JSON.stringify(t)),(e,t)=>n(e,t),()=>e.bufferedAmount)}const g={"sha-256":`sha256`,"sha-384":`sha384`,"sha-512":`sha512`};function _(e,t){let n=g[t.toLowerCase()];if(!n)throw Error(`unsupported DTLS fingerprint algorithm: ${t}`);return p(n).update(e).digest(`hex`).toUpperCase().match(/../g)?.join(`:`)??``}function v(e,t,n,r){let i=e.sctpTransport?.dtlsTransport;if(!i)return r(`no DTLS transport for the data channel`),()=>void 0;let a=!1,o=()=>{if(a)return;let e=i.dtls?.remoteCertificate;if(a=!0,!e){r(`the DTLS layer exposed no remote certificate`);return}try{n(_(e,t))}catch(e){r(e instanceof Error?e.message:String(e))}};if(i.state===`connected`)return o(),()=>void 0;if(i.state===`failed`||i.state===`closed`)return r(`the DTLS handshake ended ${i.state}`),()=>void 0;let s=i.onStateChange.subscribe(e=>{if(e===`connected`)o();else if(e===`failed`||e===`closed`){if(a)return;a=!0,r(`the DTLS handshake ended ${e}`)}else return;s.unSubscribe()});return()=>s.unSubscribe()}const y=t=>e(import.meta.url)(t);function b(e=y){try{return e(`werift`)}catch{throw Error(`WebRTC transport unavailable — install the optional peer dependency "werift" to use @robota-sdk/agent-transport-webrtc.`)}}function x(e,t){let n=t===`operator-approval`;return e.localPeer!==void 0&&t!==`local-proof`&&t!==`handoff-grant`&&!n?`local-proof`:e.handoffGrant!==void 0&&t!==`handoff-grant`&&!n?`handoff-grant`:e.connectionApproval!==void 0&&!n?`operator-approval`:null}function S(e){return typeof e==`object`&&!!e&&e.t===`handoff-grant`&&e.grant!==void 0}function C(e){return{admitted:!1,trust:`unproven`,reason:e}}async function w(e,t){if(t===void 0)return C(`no hand-off grant verifier is configured for this channel`);if(!S(e))return C(`expected a handoff-grant frame carrying the signed grant`);try{let n=await t.verify(e.grant);return n.admitted&&n.trust===`same-user-same-host`?C(`the grant verifier returned same-user-same-host. A cross-device grant proves the user, not the machine — see SEC-010 for what same-host requires.`):!n.admitted||t.consent===void 0||await t.consent(n)?n:C(`the person at this machine declined the transfer`)}catch(e){return C(`the grant verifier could not decide: ${e instanceof Error?e.message:`unknown error`}`)}}function T(e){return{t:`handoff-grant`,grant:e}}function E(e){return typeof e==`object`&&!!e&&e.t===`local-proof`&&typeof e.nonce==`string`}function D(e){return{admitted:!1,trust:`unproven`,reason:e}}function O(e,t){if(t===void 0)return D(`no local-peer proof is configured for this channel`);if(!E(e))return D(`expected a local-proof frame carrying the rendezvous nonce`);try{return t.redeem(e.nonce)}catch(e){return D(`the rendezvous ledger could not decide: ${e instanceof Error?e.message:`unknown error`}`)}}function k(e){return{t:`local-proof`,nonce:e}}function A(e,t){try{e.send(t)}catch{}}function j(e){try{e.close()}catch{}}function M(e,t,n){try{e?.(t,n)}catch{}}const N=Object.freeze({send:A,close:j,reportDeliveryError:M});function P(e,t,n,r,i,a=f){let o=a({secret:t,role:n,localFingerprint:e.localFingerprint,remoteFingerprint:e.remoteFingerprint,send:t=>N.send(e.channel,JSON.stringify(t)),...e.timeoutMs===void 0?{}:{timeoutMs:e.timeoutMs}});return o.result.then(r,i),o}function F(e,t,n,r){let i=d({hostIdentityId:t.hostIdentityId,localFingerprint:e.localFingerprint,remoteFingerprint:e.remoteFingerprint,hostPrivateKey:t.hostPrivateKey,resolveDevicePublicKey:t.resolveDevicePublicKey,send:t=>N.send(e.channel,JSON.stringify(t)),...e.timeoutMs===void 0?{}:{timeoutMs:e.timeoutMs}});return i.result.then(n,r),i}function I(e){return a(e).ok}function L(e){return o(e).ok}function R(e){return i(e).ok}function z(e,t,r){let i=e.resumeBridge;if(i)return i.attach(t=>e.channel.send(t),{awaitResume:t,onDeliveryError:r,pendingBytes:()=>e.channel.bufferedAmount}),{onSessionMessage:e=>i.onClientMessage(e),cleanup:()=>i.detach()};let{onMessage:a,cleanup:o}=(e.createHandler??n)({session:e.session,deliver:h(e.channel,r),...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{},...e.surface?{surface:e.surface}:{}});return{onSessionMessage:a,cleanup:o}}var B=class{options;state;onSessionMessage;handlerCleanup;pairingController;reconnectController;pendingResult;pendingViaReconnect=!1;pendingDeviceId;pendingEnrollment;heldFrames=[];heldChars=0;approvalAbort;constructor(e){this.options=e,e.reconnect?this.state=`awaiting-mode`:(this.state=`pairing`,this.startFirstPair())}onInbound(e){if(this.state===`closed`)return;if(this.state===`accepted`){this.onSessionMessage?.(e);return}if(this.state===`operator-approval`){this.holdForApproval(e);return}let t;try{t=JSON.parse(e)}catch{return}if(this.state===`awaiting-mode`){L(t)&&t.t===`rc-hello`?(this.state=`reconnecting`,this.startReconnect(),this.reconnectController?.onFrame(t)):I(t)&&t.t===`pair-nonce`&&(this.state=`pairing`,this.startFirstPair(),this.pairingController?.onFrame(t));return}if(this.state===`pairing`){I(t)&&this.pairingController?.onFrame(t);return}if(this.state===`reconnecting`){L(t)&&this.reconnectController?.onFrame(t);return}if(this.state===`local-proof`){let e=O(t,this.options.localPeer);this.options.localPeer?.onAdmission?.(e),e.admitted?this.accept(this.pendingResult,this.pendingViaReconnect):this.rejectAndClose();return}if(this.state===`handoff-grant`){this.judgeGrantFrame(t);return}if(this.state===`enrolling`){R(t)&&this.completeEnrollment(t.spki);return}}cleanup(){this.state=`closed`,this.withdrawApproval(),this.handlerCleanup?.(),this.handlerCleanup=void 0,this.onSessionMessage=void 0}onChannelClosed(){this.state!==`accepted`&&this.state!==`closed`&&this.rejectAndClose()}withdrawApproval(){this.heldFrames=[],this.heldChars=0,this.approvalAbort?.abort(),this.approvalAbort=void 0}startFirstPair(){this.pairingController=P(this.options,this.options.secret,this.options.role,e=>this.onFirstPairAccepted(e),()=>this.rejectAndClose(),this.options.startHandshake??f)}startReconnect(){let e=this.options.reconnect;if(!e){this.rejectAndClose();return}this.reconnectController=F(this.options,e,e=>{this.pendingDeviceId=e.deviceId,this.accept(void 0,!0)},()=>this.rejectAndClose())}onFirstPairAccepted(e){if(this.state!==`pairing`)return;this.pendingResult=e;let t=this.options.reconnect;if(!t){this.accept(e);return}this.state=`enrolling`,N.send(this.options.channel,JSON.stringify({t:`enroll-key`,spki:t.hostPublicSpki}))}completeEnrollment(e){if(this.state===`enrolling`){if(!this.options.reconnect){this.rejectAndClose();return}(async()=>{try{await u(e);let t=await s(e);if(this.state!==`enrolling`)return;this.pendingDeviceId=t,this.pendingEnrollment={deviceId:t,spki:e},this.accept(this.pendingResult)}catch{this.rejectAndClose()}})()}}accept(e,t=!1){if(this.state===`closed`||this.state===`accepted`)return;let n=x(this.options,this.state);if(n!==null){this.pendingResult=e??this.pendingResult,this.pendingViaReconnect=t,this.state=n,n===`operator-approval`&&this.askOperator();return}let r=this.pendingEnrollment;if(this.pendingEnrollment=void 0,r!==void 0)try{this.options.reconnect?.onEnroll(r.deviceId,r.spki)}catch{this.rejectAndClose();return}let i=z(this.options,t,(e,t)=>this.handleSessionDeliveryError(e,t));this.onSessionMessage=i.onSessionMessage,this.handlerCleanup=i.cleanup,this.state=`accepted`,this.options.onAccept?.(e);let a=this.heldFrames;this.heldFrames=[],this.heldChars=0;for(let e of a){if(this.state!==`accepted`)return;this.onSessionMessage?.(e)}}holdForApproval(e){this.heldChars+=e.length,this.heldFrames.push(e),(this.heldFrames.length>32||this.heldChars>262144)&&this.rejectAndClose()}async askOperator(){let e=new AbortController;this.approvalAbort=e;let t={...this.pendingDeviceId===void 0?{}:{deviceId:this.pendingDeviceId},viaReconnect:this.pendingViaReconnect,signal:e.signal},n=!1;try{n=await this.options.connectionApproval?.approve(t)===!0}catch{n=!1}this.state!==`operator-approval`||e.signal.aborted||(this.approvalAbort=void 0,n?this.accept(this.pendingResult,this.pendingViaReconnect):this.rejectAndClose())}async judgeGrantFrame(e){let t=await w(e,this.options.handoffGrant);this.state===`handoff-grant`&&(this.options.handoffGrant?.onAdmission?.(t),t.admitted?this.accept(this.pendingResult,this.pendingViaReconnect):this.rejectAndClose())}rejectAndClose(){this.state!==`closed`&&(this.state=`closed`,this.withdrawApproval(),N.close(this.options.channel),this.options.onReject?.())}handleSessionDeliveryError(e,t){this.state!==`closed`&&(this.state=`closed`,this.handlerCleanup?.(),this.handlerCleanup=void 0,this.onSessionMessage=void 0,N.reportDeliveryError(this.options.onDeliveryError,e,t),N.close(this.options.channel))}};function V(e){return Object.assign(Error(`WebRtcTransport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`webrtc`})}var H=class{options;generation=0;paired=!1;dropped=!1;constructor(e){this.options=e}reset(e){this.generation=e,this.paired=!1,this.dropped=!1}accept(e){e===this.generation&&(this.paired=!0)}handleDrop(e){e!==this.generation||!this.paired||this.dropped||(this.dropped=!0,this.options.cleanup(),this.options.onDropped())}handleFailure(e,t,n,r){if(t!==this.generation||this.dropped)return;let i=this.paired;i&&(this.dropped=!0);try{this.options.onDeliveryError(n,r)}catch{}this.options.cleanup();try{e.close()}catch{}i&&this.options.onDropped()}},U=class{options;name=`webrtc`;lifecycle=Object.freeze({kind:`service`});defaultEnabled=!1;optionsSchema={};session;peer;unsubscribeSignal;cleanupHandler;generation=0;localFingerprint;pairingGate;answered=!1;pendingFrames=[];stopAwaitingCertificate;deliveryLifecycle;constructor(e){if(this.options=e,this.deliveryLifecycle=new H({cleanup:()=>{this.cleanupHandler?.(),this.pairingGate?.cleanup()},onDropped:()=>this.options.onDropped?.(),onDeliveryError:(e,t)=>this.options.onDeliveryError?.(e,t)}),this.options.secret&&this.options.open===!0)throw Error("WebRtcTransport: `secret` and `open: true` are contradictory. A pairing secret gates the data channel; `open` runs without a gate. Pass one.");if(this.options.secret===void 0||this.options.secret===``){if(this.options.open!==!0)throw Error('WebRtcTransport: no pairing `secret` and no explicit `open`. Pass a `secret` to gate the data channel, or `{ open: true, openReason: "…" }` to run without pairing on purpose.');r({open:!0,...this.options.openReason===void 0?{}:{openReason:this.options.openReason}})}}validateOptions(){return!0}attach(e){this.session=e}createPeer(){let{RTCPeerConnection:e}=(this.options.loadWerift??b)(),t={};return this.options.iceServers&&(t.iceServers=this.options.iceServers.map(e=>({...e}))),this.options.forceTurn&&(t.iceTransportPolicy=`relay`),new e(Object.keys(t).length>0?t:void 0)}wireSignaling(e,t,n,r){let i=this.options.signaling;e.onIceCandidate.subscribe(t=>{t&&r===this.generation&&e===this.peer&&i.send({kind:`ice`,data:t.toJSON()})});let a=Promise.resolve();this.unsubscribeSignal=i.onSignal(i=>{r===this.generation&&(a=a.then(async()=>{if(r===this.generation&&e===this.peer)if(i.kind===`answer`){if(this.answered)return;this.answered=!0;let a=this.remoteFingerprintAlgorithm(t,i.data);if(this.options.secret&&a===void 0)return;await e.setRemoteDescription(i.data),a!==void 0&&this.awaitVerifiedCertificate(e,t,n,a,r)}else i.kind===`ice`&&await e.addIceCandidate(i.data)}))})}async requireCurrentPeer(e,t){if(t!==this.generation||this.peer!==e)throw await e.close(),Error(`WebRtcTransport startup was stopped.`)}async start(){let e=this.session;if(!e)throw V(`not-attached`);if(this.peer)throw V(`already-started`);let t=++this.generation;this.deliveryLifecycle.reset(t),this.answered=!1,this.pendingFrames=[];let n=this.createPeer();this.peer=n;let r=this.options.signaling,i=n.createDataChannel(`robota-session`);this.wireChannel(i,e,t),this.wireSignaling(n,i,e,t);let a=await n.createOffer();await this.requireCurrentPeer(n,t),await n.setLocalDescription(a),await this.requireCurrentPeer(n,t),this.options.secret&&n.localDescription&&(this.localFingerprint=c(n.localDescription.sdp)),r.send({kind:`offer`,data:n.localDescription})}remoteFingerprintAlgorithm(e,t){if(!this.options.secret)return;let n=t.sdp;try{if(typeof n!=`string`)throw Error(`answer carries no SDP`);return l(n).algorithm}catch{this.failPairing(e);return}}failPairing(e){try{e.close()}catch{}this.options.onPairingFailed?.()}awaitVerifiedCertificate(e,t,n,r,i){this.stopAwaitingCertificate=v(e,r,e=>{i===this.generation&&this.startPairing(t,n,e,i)},()=>{i===this.generation&&this.failPairing(t)})}startPairing(e,t,n,r){let i=this.options.secret;if(!i||!this.localFingerprint)return;this.pairingGate=new B({channel:{send:t=>e.send(t),close:()=>void e.close()},session:t,secret:i,role:`initiator`,localFingerprint:this.localFingerprint,remoteFingerprint:n,onAccept:e=>{r===this.generation&&(this.deliveryLifecycle.accept(r),this.options.onPaired?.(e))},...this.options.onPairingFailed?{onReject:this.options.onPairingFailed}:{},...this.options.reconnect?{reconnect:this.options.reconnect}:{},...this.options.localPeer?{localPeer:this.options.localPeer}:{},...this.options.connectionApproval?{connectionApproval:this.options.connectionApproval}:{},...this.options.resumeBridge?{resumeBridge:this.options.resumeBridge}:{},...this.options.personalUsageReporter?{personalUsageReporter:this.options.personalUsageReporter}:{},...this.options.usageReporter?{usageReporter:this.options.usageReporter}:{},...this.options.storedSessionUsageReporter?{storedSessionUsageReporter:this.options.storedSessionUsageReporter}:{},surface:`remote`,onDeliveryError:(t,n)=>this.deliveryLifecycle.handleFailure(e,r,t,n)});let a=this.pairingGate;for(let e of this.pendingFrames.splice(0))a.onInbound(e)}wireChannel(e,t,r){if(this.options.secret){e.onMessage.subscribe(e=>{if(r!==this.generation)return;let t=typeof e==`string`?e:e.toString();this.pairingGate?this.pairingGate.onInbound(t):this.pendingFrames.length<16&&this.pendingFrames.push(t)}),e.stateChanged.subscribe(e=>{r===this.generation&&(e===`closed`||e===`closing`)&&(this.pairingGate?.onChannelClosed(),this.deliveryLifecycle.handleDrop(r))}),this.cleanupHandler=()=>this.pairingGate?.cleanup();return}let{onMessage:i,cleanup:a}=n({session:t,deliver:h(e,(t,n)=>this.deliveryLifecycle.handleFailure(e,r,t,n)),...this.options.personalUsageReporter?{personalUsageReporter:this.options.personalUsageReporter}:{},...this.options.usageReporter?{usageReporter:this.options.usageReporter}:{},...this.options.storedSessionUsageReporter?{storedSessionUsageReporter:this.options.storedSessionUsageReporter}:{},surface:`remote`});this.cleanupHandler=a,e.onMessage.subscribe(e=>{r===this.generation&&i(typeof e==`string`?e:e.toString())})}async stop(){this.generation+=1,this.cleanupHandler?.(),this.unsubscribeSignal?.(),this.cleanupHandler=void 0,this.unsubscribeSignal=void 0,this.pairingGate=void 0,this.localFingerprint=void 0,this.answered=!1,this.pendingFrames=[],this.stopAwaitingCertificate?.(),this.stopAwaitingCertificate=void 0,this.deliveryLifecycle.reset(this.generation),this.peer&&=(await this.peer.close(),void 0),this.session=void 0}};function W(){let e=[],t=[],n=!0;function r(e,t){return{send(e){n&&queueMicrotask(()=>{for(let n of t)n(e)})},onSignal(t){return e.push(t),()=>{let n=e.indexOf(t);n>=0&&e.splice(n,1)}},close(){n=!1,e.length=0}}}return[r(e,t),r(t,e)]}const G=new Set([`offer`,`answer`,`ice`]);function K(e){return new m(e)}var q=class{options;socket;handlers=[];outbox=[];joined=!1;closed=!1;constructor(e){this.options=e;let t=e.createSocket??K;this.socket=t(e.url),this.socket.on(`open`,()=>this.handleOpen()),this.socket.on(`message`,e=>this.handleMessage(e)),this.socket.on(`error`,e=>this.fail(e instanceof Error?e:Error(String(e)))),this.socket.on(`close`,()=>this.handleClose())}handleOpen(){this.socket.send(JSON.stringify({type:`join`,rendezvous:this.options.rendezvous}));for(let e of this.outbox)this.writeSignal(e);this.outbox.length=0}handleMessage(e){let t;try{t=JSON.parse(typeof e==`string`?e:String(e))}catch{return}if(typeof t!=`object`||!t)return;let n=t;if(n.type===`joined`){this.joined=!0,this.options.onReady?.();return}if(n.type===`error`){this.fail(Error(`signaling relay rejected the connection: ${String(n.reason)}`));return}if(n.type===`signal`&&typeof n.kind==`string`&&G.has(n.kind)){let e={kind:n.kind,data:n.data};for(let t of this.handlers)t(e)}}handleClose(){this.closed||this.joined||this.fail(Error(`signaling socket closed before the rendezvous was joined`))}fail(e){this.options.onError?.(e)}writeSignal(e){this.socket.send(JSON.stringify({type:`signal`,kind:e.kind,data:e.data}))}send(e){this.closed||(this.socket.readyState===1?this.writeSignal(e):this.outbox.push(e))}onSignal(e){return this.handlers.push(e),()=>{let t=this.handlers.indexOf(e);t>=0&&this.handlers.splice(t,1)}}close(){this.closed=!0,this.handlers.length=0,this.outbox.length=0,this.socket.close()}};export{U as WebRtcTransport,q as WsSignalingClient,W as createInMemorySignalingPair,T as handoffGrantFrame,b as loadWerift,k as localProofFrame};
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["refuse"],"sources":["../../src/channel-delivery.ts","../../src/negotiated-certificate.ts","../../src/werift-loader.ts","../../src/admission-steps.ts","../../src/handoff-grant-gate.ts","../../src/local-peer-proof.ts","../../src/pairing-channel-lifecycle.ts","../../src/pairing-controllers.ts","../../src/pairing-frames.ts","../../src/session-attachment.ts","../../src/pairing-gate.ts","../../src/transport-lifecycle-error.ts","../../src/webrtc-delivery-lifecycle.ts","../../src/webrtc-transport.ts","../../src/signaling.ts","../../src/ws-signaling-client.ts"],"sourcesContent":["/**\n * The data channel's outbound delivery boundary (ARCH-030).\n *\n * Both places that build a bare `createSessionMessageHandler` in this package — `PairingGate`'s no-`resumeBridge`\n * branch and `WebRtcTransport`'s no-secret branch — need exactly the same thing: serialize a\n * `TServerMessage` onto an `RTCDataChannel` and route a send failure into that carrier's own failure\n * policy. Written inline at both sites it was two copies of one decision, and it pushed both files past\n * their size ceilings. One owner, named after what it is.\n */\n\nimport { createOutboundDelivery } from '@robota-sdk/agent-transport';\n\nimport type { TOutboundDeliver } from '@robota-sdk/agent-transport';\n\n/**\n * The slice of `RTCDataChannel` an outbound boundary needs.\n *\n * `bufferedAmount` is optional because the slice is structural — a test double may omit it — but a\n * real `RTCDataChannel` always has it, so the production path reports a real number rather than a\n * placeholder (ARCH-030 / issue #1734).\n */\nexport interface IDataChannelSink {\n send: (data: string) => void;\n readonly bufferedAmount?: number;\n}\n\n/**\n * Build the connection's outbound boundary over a data channel.\n *\n * @param channel - the data channel to serialize onto. `send` may throw once the channel is closing.\n * @param onDeliveryError - this carrier's failure policy, invoked at most once (the boundary latches).\n */\nexport function createChannelDelivery(\n channel: IDataChannelSink,\n onDeliveryError: (error: Error, event: string) => void,\n): TOutboundDeliver {\n return createOutboundDelivery(\n (message) => channel.send(JSON.stringify(message)),\n (error, event) => onDeliveryError(error, event),\n // Read at CALL time, not captured: `bufferedAmount` is a live property and a value read once\n // would answer with the backpressure of the moment the boundary was built.\n () => channel.bufferedAmount,\n );\n}\n","/**\n * The remote fingerprint the pairing confirmation binds to, read from the certificate the DTLS layer verified.\n *\n * The SDP is delivered by an untrusted signaling path, and a DTLS stack accepts the remote certificate when it\n * matches ANY fingerprint the SDP advertises. Binding a value read from SDP text would therefore let the bound\n * fingerprint and the verified certificate differ. Reading the certificate itself, after the DTLS layer has\n * verified it, makes the binding and the connection share one source of truth.\n */\n\nimport { createHash } from 'node:crypto';\n\nimport type { RTCPeerConnection } from 'werift';\n\nconst HASHES: Readonly<Record<string, string>> = {\n 'sha-256': 'sha256',\n 'sha-384': 'sha384',\n 'sha-512': 'sha512',\n};\n\n/** SDP-form fingerprint (`AB:CD:…`, upper case) of a DER certificate. Throws on an unsupported algorithm. */\nexport function certificateFingerprint(der: Uint8Array, algorithm: string): string {\n const hash = HASHES[algorithm.toLowerCase()];\n if (!hash) throw new Error(`unsupported DTLS fingerprint algorithm: ${algorithm}`);\n const hex = createHash(hash).update(der).digest('hex').toUpperCase();\n return hex.match(/../g)?.join(':') ?? '';\n}\n\n/**\n * Call `onVerified` with the remote certificate's fingerprint once the DTLS handshake — including its own\n * fingerprint check — has completed, or `onFailed` if the handshake fails or closes first, or the certificate\n * cannot be read. Returns an unsubscribe function.\n *\n * werift moves the DTLS transport to `connected` only after `verifyRemoteCertificateFingerprint` succeeds, and\n * the data channel runs over that DTLS session, so no channel frame can precede this callback.\n */\nexport function whenRemoteCertificateVerified(\n peer: RTCPeerConnection,\n algorithm: string,\n onVerified: (fingerprint: string) => void,\n onFailed: (reason: string) => void,\n): () => void {\n const dtlsTransport = peer.sctpTransport?.dtlsTransport;\n if (!dtlsTransport) {\n onFailed('no DTLS transport for the data channel');\n return () => undefined;\n }\n let done = false;\n const settle = (): void => {\n if (done) return;\n const der = dtlsTransport.dtls?.remoteCertificate;\n done = true;\n if (!der) {\n onFailed('the DTLS layer exposed no remote certificate');\n return;\n }\n try {\n onVerified(certificateFingerprint(der, algorithm));\n } catch (error) {\n onFailed(error instanceof Error ? error.message : String(error));\n }\n };\n if (dtlsTransport.state === 'connected') {\n settle();\n return () => undefined;\n }\n if (dtlsTransport.state === 'failed' || dtlsTransport.state === 'closed') {\n onFailed(`the DTLS handshake ended ${dtlsTransport.state}`);\n return () => undefined;\n }\n const subscription = dtlsTransport.onStateChange.subscribe((state) => {\n if (state === 'connected') settle();\n else if (state === 'failed' || state === 'closed') {\n if (done) return;\n done = true;\n onFailed(`the DTLS handshake ended ${state}`);\n } else return;\n subscription.unSubscribe();\n });\n return () => subscription.unSubscribe();\n}\n","import { createRequire } from 'node:module';\n\nimport type { RTCPeerConnection } from 'werift';\n\n/** The subset of the `werift` module surface this transport constructs. */\nexport interface IWeriftModule {\n RTCPeerConnection: new (configuration?: {\n // REMOTE-010: werift's ICE gatherer consumes only a SINGLE-string `urls` (array urls / `turns:` are silently\n // dropped by its `parseIceServers`); TURN servers carry username/credential. Relay-only comes from\n // `iceTransportPolicy:'relay'` — werift IGNORES a top-level `forceTurn`, so this type must NOT declare it.\n iceServers?: { urls: string; username?: string; credential?: string }[];\n iceTransportPolicy?: 'all' | 'relay';\n }) => RTCPeerConnection;\n}\n\n/**\n * Lazily load the optional `werift` (pure-TypeScript WebRTC) peer dependency (REMOTE-002). `werift` is kept\n * OUT of the transport's runtime dependency graph (peer + optional) so this package is importable without it;\n * absence surfaces an **explicit \"WebRTC transport unavailable\" error at point-of-use** (a throw, mirroring\n * `agent-cli`'s `loadReplayProvider`) — never a silent no-op or degraded path (no-fallback rule). The\n * werift-vs-native choice is a recorded design decision, not a runtime fallback.\n */\n/** Resolve a module by id. The default resolves the real `werift`; tests inject a throwing resolver. */\nexport type TModuleResolver = (id: string) => unknown;\n\nconst defaultResolver: TModuleResolver = (id) => {\n const requireFrom = createRequire(import.meta.url);\n // eslint-disable-next-line @typescript-eslint/no-require-imports -- optional peer; must resolve at runtime, not bundle\n return requireFrom(id);\n};\n\nexport function loadWerift(resolve: TModuleResolver = defaultResolver): IWeriftModule {\n try {\n return resolve('werift') as IWeriftModule;\n } catch {\n throw new Error(\n 'WebRTC transport unavailable — install the optional peer dependency \"werift\" to use @robota-sdk/agent-transport-webrtc.',\n );\n }\n}\n","/**\n * Which admission step a channel still owes before the session may be exposed.\n *\n * Extracted from `pairing-gate.ts` because it is the one decision in that file that is a pure\n * function of configuration and state — and because it is the ORDERING that carries the security\n * argument, so it deserves to be readable and testable without a channel, a handshake or a session.\n *\n * The order is not arbitrary. Each step must run on a channel the previous one has already bound:\n *\n * handshake binds the CHANNEL — it terminates at the peer that knew the secret\n * local-proof binds the ENVIRONMENT — that peer reached a 0700 directory owned by this user\n * handoff-grant binds the TRANSFER — one user, one destination, one channel, signed\n * operator-approval the receiving operator allows THIS connection — asked last, once every proof\n * has run, so the operator is asked only about a peer that is who it claims\n *\n * Each is opt-in, and configuring two means requiring both. That is strictly more restrictive than\n * requiring either, which is the safe direction — and it is the correct reading for a hand-off\n * between two sessions on ONE machine, which legitimately has both to present.\n */\n\n/** The step still owed, or null when the session may be exposed. */\nexport type TAdmissionStep = 'local-proof' | 'handoff-grant' | 'operator-approval' | null;\n\n/** What the gate has configured. Named structurally so this module needs none of the gate's types. */\nexport interface IConfiguredAdmissionSteps {\n readonly localPeer?: unknown;\n readonly handoffGrant?: unknown;\n readonly connectionApproval?: unknown;\n}\n\n/**\n * The next step, given what is configured and what has already run.\n *\n * `completed` is the gate's current state rather than a set of finished steps, because the states\n * ARE the record: reaching `handoff-grant` is only possible by having satisfied `local-proof` when\n * one was required.\n */\nexport function nextAdmissionStep(\n configured: IConfiguredAdmissionSteps,\n completed: string,\n): TAdmissionStep {\n const approved = completed === 'operator-approval';\n if (\n configured.localPeer !== undefined &&\n completed !== 'local-proof' &&\n completed !== 'handoff-grant' &&\n !approved\n ) {\n return 'local-proof';\n }\n if (configured.handoffGrant !== undefined && completed !== 'handoff-grant' && !approved)\n return 'handoff-grant';\n if (configured.connectionApproval !== undefined && !approved) return 'operator-approval';\n return null;\n}\n","/**\n * SEC-011 (issue #1865): the channel gate consumes the cross-device hand-off grant.\n *\n * The same shape SEC-010's local-proof step established, for the same reason. #1810 is explicit that\n * this package must not implement cryptographic policy, so nothing here verifies a signature,\n * decides an expiry, or knows what revocation is. The gate holds the state machine; the verdict is\n * injected and reported.\n *\n * ## What the grant proves, and what the channel proves\n *\n * The pairing handshake binds the CHANNEL: after it, this data channel provably terminates at the\n * peer that knew the secret, and the DTLS fingerprints are covered so it cannot be spliced. It says\n * nothing about who that peer is.\n *\n * The grant binds the TRANSFER: one user, one source device, one destination, one `handoffId`, one\n * `sessionId`, one nonce, and the fingerprint of the channel it may be presented over. It says\n * nothing about whether the channel it arrived on is the one it names.\n *\n * Presenting the grant OVER the already-bound channel is what joins them, and the fingerprint claim\n * is what makes the join checkable: a grant lifted from one channel and replayed on another names a\n * fingerprint the verifier does not observe. That is `channel-substituted`, and it is a distinct\n * rejection precisely so it cannot be softened into a pass.\n *\n * ## Why it runs after the handshake and before the session\n *\n * Both edges are load-bearing, and both for SEC-010's reasons.\n *\n * BEFORE the handshake completes, the channel is not authenticated — a grant presented there would\n * be handed to an unproven counterpart, and this step would weaken what it exists to strengthen.\n *\n * BEFORE the session is exposed, because \"fail closed before content\" is the first failure rule. A\n * peer that never presents a grant must not have been talking to the session already; refusing it\n * afterwards is not refusing it.\n *\n * ## The trust it produces is its own\n *\n * `same-user-different-host` is not `same-user-same-host`. #1812 pins that they are different values\n * and this gate is the reason: a cross-device authorization must never satisfy a check that wanted\n * same-machine. It travels as a value on the admission rather than being re-derived from a boolean\n * by whoever reads it next.\n */\n\nimport type { IPeerAdmission } from '@robota-sdk/agent-interface-session-mobility';\n\n/** The frame a source device presents to show it holds a grant for this transfer. */\nexport interface IHandoffGrantFrame {\n readonly t: 'handoff-grant';\n /**\n * The signed grant, carried opaquely.\n *\n * `unknown` on purpose: this package cannot import the grant type without importing the package\n * that owns the crypto, and typing it structurally here would create a second declaration of a\n * signed object's shape — which is how a field ends up checked in one copy and not the other.\n * The verifier owns the type; this gate owns the envelope.\n */\n readonly grant: unknown;\n}\n\n/**\n * The cross-device authorization this gate requires before exposing the session.\n *\n * Absent → the gate behaves exactly as before. Requiring a grant is opt-in because an ordinary\n * remote peer has no hand-off to authorize, and a gate that demanded one unconditionally would\n * refuse every legitimate remote session.\n */\nexport interface IHandoffGrantProof {\n /**\n * Judge a presented grant. Owned by the verifier — this package asks and does not decide.\n *\n * Async because verifying a signature is: `verifyHandoffGrant` returns a promise, and a\n * synchronous seam here would force the owner to block or to pre-verify, and pre-verifying means\n * deciding before the channel that the grant is bound to even exists.\n *\n * Returns the admission the consumer receives, so the trust level travels as a value.\n */\n readonly verify: (grant: unknown) => Promise<IPeerAdmission>;\n /**\n * Ask the person at THIS machine whether to accept the transfer. Absent → no consent is required.\n *\n * Called ONLY on a verdict the verifier admitted, and given that verdict, for two reasons that\n * both matter:\n *\n * The prompt can then name a PROVEN origin. Asking before verification would let anyone who can\n * open a channel raise a dialog on someone's machine claiming to be any device they like, which\n * turns the consent step into an attack surface instead of a control.\n *\n * And consent is not cryptographic policy, so it does not belong inside `verify`. A verifier that\n * also asked a human would make \"is this grant valid\" and \"does this person want it\" one answer,\n * and the first is the one that must be decidable without a person present.\n *\n * Returning false — or throwing, or having no renderer to ask — refuses. Denial fails CLOSED.\n */\n readonly consent?: (admission: IPeerAdmission) => Promise<boolean>;\n /**\n * Fired on the outcome, admitted or not.\n *\n * On BOTH, deliberately: a consumer told only about successes cannot distinguish \"no hand-off was\n * offered\" from \"a hand-off was refused\", and those call for different operator responses — the\n * second is the one that belongs in front of a person.\n */\n readonly onAdmission?: (admission: IPeerAdmission) => void;\n}\n\n/**\n * True when a parsed pre-accept value is a grant frame.\n *\n * Module-private: `judgeHandoffGrant` is the only thing that should ask. Exporting it would offer a\n * caller a way to check the shape and then act on it without going through the judge, which is how\n * an admission decision ends up made in two places.\n */\nfunction isHandoffGrantFrame(value: unknown): value is IHandoffGrantFrame {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { t?: unknown }).t === 'handoff-grant' &&\n (value as { grant?: unknown }).grant !== undefined\n );\n}\n\n/** A refusal shaped like every other admission, so no caller has to special-case the failure path. */\nfunction refuse(reason: string): IPeerAdmission {\n return { admitted: false, trust: 'unproven', reason };\n}\n\n/**\n * Judge one pre-accept frame against the configured grant proof.\n *\n * Every path that is not an admitted verification returns a refusal — including a missing config,\n * which cannot happen through the gate but would be a fail-OPEN if it ever did.\n */\nexport async function judgeHandoffGrant(\n parsed: unknown,\n proof: IHandoffGrantProof | undefined,\n): Promise<IPeerAdmission> {\n if (proof === undefined)\n return refuse('no hand-off grant verifier is configured for this channel');\n if (!isHandoffGrantFrame(parsed)) {\n return refuse('expected a handoff-grant frame carrying the signed grant');\n }\n try {\n const admission = await proof.verify(parsed.grant);\n // The verifier owns the verdict, but not the ability to widen it. A cross-device grant cannot\n // establish that the peer is on THIS machine, so an admission claiming it did is refused rather\n // than passed on — an implementation that returned the stronger level would otherwise satisfy\n // every check that wanted same-machine, which is the one substitution #1812 forbids by name.\n if (admission.admitted && admission.trust === 'same-user-same-host') {\n return refuse(\n 'the grant verifier returned same-user-same-host. A cross-device grant proves the user, ' +\n 'not the machine — see SEC-010 for what same-host requires.',\n );\n }\n if (!admission.admitted || proof.consent === undefined) return admission;\n // The person, after the proof. A refusal here is not a weaker outcome than a cryptographic one:\n // the session is equally not exposed, and the reason says which step declined so the operator is\n // not left reading \"unauthorized\" when what happened is that they said no.\n if (!(await proof.consent(admission))) {\n return refuse('the person at this machine declined the transfer');\n }\n return admission;\n } catch (error) {\n // allow-fallback: fail-CLOSED, not a fallback to a degraded path. A verifier that throws has not\n // reached a decision, and \"not reached\" is not \"allowed\" — the refusal below is strictly more\n // restrictive than the success path, never less. Letting it propagate would be worse: the throw\n // unwinds through the channel's message subscription and leaves the gate parked in its grant\n // state with the channel open, which is a hang — fail-open wearing a crash's clothes.\n return refuse(\n `the grant verifier could not decide: ${\n error instanceof Error ? error.message : 'unknown error'\n }`,\n );\n }\n}\n\n/**\n * Build the frame a source device presents on the channel.\n *\n * Lives beside the judge that reads it, so the two cannot drift: a sender that hand-built the object\n * would keep compiling after the frame gains a field, and the failure would surface as a refusal on\n * the far side with no hint that the SENDER is the stale half.\n *\n * Deliberately not a \"send\" — it returns the frame and leaves transmission to whoever owns the\n * channel, for the same reason `localProofFrame` does.\n */\nexport function handoffGrantFrame(grant: unknown): IHandoffGrantFrame {\n return { t: 'handoff-grant', grant };\n}\n","/**\n * SEC-010 TC-08: joining the rendezvous proof to the channel the session will run on.\n *\n * ## What each half proves, and why neither is enough alone\n *\n * The pairing handshake binds the CHANNEL: after it, this data channel provably terminates at the\n * peer that knew the secret, and the DTLS fingerprints are covered so it cannot be spliced. It says\n * nothing about where that peer runs.\n *\n * The guarded rendezvous binds the ENVIRONMENT: a peer that reached a 0700 directory owned by this\n * user could only have come from this machine, as this user. It says nothing about which channel\n * that peer later opens.\n *\n * Presenting the rendezvous nonce OVER the already-bound channel is what joins them. The channel is\n * authenticated by the time this runs, so nobody else can inject the frame; the nonce is single-use,\n * so nobody can re-present one they observed. Together they say: the party on this channel is the\n * party the kernel vouched for, on this attempt.\n *\n * ## Why this runs after the handshake and before the session\n *\n * Both edges are load-bearing.\n *\n * BEFORE the handshake completes, the channel is not yet authenticated — a nonce presented there\n * would be a secret handed to an unproven counterpart, and this step would weaken the very thing it\n * exists to strengthen.\n *\n * BEFORE the session is exposed, because \"fail closed before content\" is SEC-010's first failure\n * rule. If the proof arrived after exposure, a peer that never presents one would have already been\n * talking to the session, and refusing it afterwards is not refusing it.\n *\n * ## This package decides nothing\n *\n * #1810 is explicit that WebRTC must not implement cryptographic policy. Single-use, expiry and\n * revocation belong to the grant ledger in `@robota-sdk/agent-remote-pairing/local`, which is\n * node-only; this module takes an injected `redeem` and reports what it was told. That also keeps\n * `node:fs` out of this package, and keeps the ledger testable without a data channel.\n */\n\nimport type { IPeerAdmission } from '@robota-sdk/agent-interface-session-mobility';\n\n/** The frame a local peer presents to show it reached the guarded rendezvous. */\nexport interface ILocalProofFrame {\n readonly t: 'local-proof';\n readonly nonce: string;\n}\n\n/**\n * The local-environment proof this gate requires before exposing the session.\n *\n * Absent → the gate behaves exactly as before. Requiring the proof is opt-in because a remote peer\n * over WebRTC has no rendezvous to have reached, and a gate that demanded one unconditionally would\n * refuse every legitimate remote session.\n */\nexport interface ILocalPeerProof {\n /**\n * Redeem a presented nonce. Owned by the ledger — this package asks and does not decide.\n *\n * Returns the admission the consumer receives, so the trust level travels as a value rather than\n * being re-derived from a boolean by whoever reads it next.\n */\n readonly redeem: (nonce: string) => IPeerAdmission;\n /**\n * Fired on the outcome, admitted or not.\n *\n * On BOTH, deliberately: a consumer told only about successes cannot distinguish \"no local peer\n * connected\" from \"a local peer was refused\", and those call for different operator responses.\n */\n readonly onAdmission?: (admission: IPeerAdmission) => void;\n}\n\n/**\n * True when a parsed pre-accept value is a local-proof frame carrying a nonce.\n *\n * Module-private: `judgeLocalProof` is the only thing that should ask. Exporting it would offer a\n * caller a way to check the shape and then act on it without going through the judge, which is how\n * an admission decision ends up made in two places.\n */\nfunction isLocalProofFrame(value: unknown): value is ILocalProofFrame {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { t?: unknown }).t === 'local-proof' &&\n typeof (value as { nonce?: unknown }).nonce === 'string'\n );\n}\n\n/** A refusal shaped like every other admission, so no caller has to special-case the failure path. */\nfunction refuse(reason: string): IPeerAdmission {\n return { admitted: false, trust: 'unproven', reason };\n}\n\n/**\n * Judge one pre-accept frame against the configured proof.\n *\n * Every path that is not an admitted redemption returns a refusal — including a missing config,\n * which cannot happen through the gate but would be a fail-OPEN if it ever did.\n */\nexport function judgeLocalProof(\n parsed: unknown,\n proof: ILocalPeerProof | undefined,\n): IPeerAdmission {\n if (proof === undefined) return refuse('no local-peer proof is configured for this channel');\n if (!isLocalProofFrame(parsed)) {\n return refuse('expected a local-proof frame carrying the rendezvous nonce');\n }\n try {\n return proof.redeem(parsed.nonce);\n } catch (error) {\n // allow-fallback: fail-CLOSED, not a fallback to a degraded path. A ledger that throws has not\n // reached a decision, and \"not reached\" is not \"allowed\" — the refusal below is strictly more\n // restrictive than the success path, never less. Letting it propagate would be worse: the throw\n // unwinds through the channel's message subscription and leaves the gate parked in its proof\n // state with the channel open, which is a hang — fail-open wearing a crash's clothes.\n return refuse(\n `the rendezvous ledger could not decide: ${\n error instanceof Error ? error.message : 'unknown error'\n }`,\n );\n }\n}\n\n/**\n * Build the frame a local peer presents on the channel.\n *\n * Lives beside the judge that reads it, so the two cannot drift: a sender that hand-built the object\n * would keep compiling after the frame gains a field, and the failure would surface as a refusal on\n * the far side with no hint that the SENDER is the stale half.\n *\n * Deliberately not a \"send\" — it returns the frame and leaves transmission to whoever owns the\n * channel. A helper that both built and sent would need a channel to be testable, and would put the\n * frame's shape and the carrier's lifecycle in one place.\n */\nexport function localProofFrame(nonce: string): ILocalProofFrame {\n return { t: 'local-proof', nonce };\n}\n","import type { IPairingChannel } from './pairing-gate-options.js';\n\nfunction send(channel: IPairingChannel, data: string): void {\n try {\n channel.send(data);\n } catch {\n // The peer is gone; pairing frames are best-effort once the carrier is closing.\n }\n}\n\nfunction close(channel: IPairingChannel): void {\n try {\n channel.close();\n } catch {\n // The carrier is already closing or closed.\n }\n}\n\nfunction reportDeliveryError(\n callback: ((error: Error, event: string) => void) | undefined,\n error: Error,\n event: string,\n): void {\n try {\n callback?.(error, event);\n } catch {\n // A diagnostic callback cannot prevent carrier teardown or escape into the committed operation.\n }\n}\n\nexport const pairingChannel = Object.freeze({ send, close, reportDeliveryError });\n","/**\n * Constructing the admission controllers the gate drives.\n *\n * \"Which controller runs, with what inputs\" is a different subject from \"what state is the gate in\n * and what may reach the session\" — the same boundary that already moved the frame vocabulary into\n * `pairing-frames.ts` and the best-effort channel calls into `pairing-channel-lifecycle.ts`.\n *\n * Split out because `pairing-gate.ts` had reached the size limit, where the rule is to split rather\n * than extend. These two functions were the largest thing in it that was not about gating, and they\n * touch nothing of the gate's own state: they take inputs, wire a controller's `send` to the\n * channel, and hand back the controller plus a settled/rejected outcome for the caller to act on.\n */\n\nimport { startHostReconnect, startPairingHandshake } from '@robota-sdk/agent-remote-pairing';\n\nimport { pairingChannel } from './pairing-channel-lifecycle.js';\n\nimport type { IHostReconnectConfig, IPairingChannel } from './pairing-gate-options.js';\nimport type {\n IPairingResult,\n IReconnectResult,\n TPairingRole,\n} from '@robota-sdk/agent-remote-pairing';\n\n/** What both controllers need to talk on the channel and bind to it. */\nexport interface IControllerContext {\n readonly channel: IPairingChannel;\n readonly localFingerprint: string;\n readonly remoteFingerprint: string;\n /** Handshake timeout (ms); fail closed on expiry. */\n readonly timeoutMs?: number;\n}\n\n/**\n * Start the B3 first-pair handshake.\n *\n * `onAccepted`/`onRejected` rather than handing the promise back, so a caller cannot forget to\n * attach a rejection path — an unhandled rejection here would leave a gate waiting forever on a\n * handshake that already failed, which is a fail-OPEN shaped as a hang.\n */\nexport function startFirstPairController(\n context: IControllerContext,\n secret: string,\n role: TPairingRole,\n onAccepted: (result: IPairingResult) => void,\n onRejected: () => void,\n start: typeof startPairingHandshake = startPairingHandshake,\n): ReturnType<typeof startPairingHandshake> {\n const controller = start({\n secret,\n role,\n localFingerprint: context.localFingerprint,\n remoteFingerprint: context.remoteFingerprint,\n send: (frame) => pairingChannel.send(context.channel, JSON.stringify(frame)),\n ...(context.timeoutMs !== undefined ? { timeoutMs: context.timeoutMs } : {}),\n });\n controller.result.then(onAccepted, onRejected);\n return controller;\n}\n\n/** Start the E3 host-side reconnect exchange against a pinned device key. */\nexport function startReconnectController(\n context: IControllerContext,\n config: IHostReconnectConfig,\n onAccepted: (result: IReconnectResult) => void,\n onRejected: () => void,\n): ReturnType<typeof startHostReconnect> {\n const controller = startHostReconnect({\n hostIdentityId: config.hostIdentityId,\n localFingerprint: context.localFingerprint,\n remoteFingerprint: context.remoteFingerprint,\n hostPrivateKey: config.hostPrivateKey,\n resolveDevicePublicKey: config.resolveDevicePublicKey,\n send: (frame) => pairingChannel.send(context.channel, JSON.stringify(frame)),\n ...(context.timeoutMs !== undefined ? { timeoutMs: context.timeoutMs } : {}),\n });\n controller.result.then(onAccepted, onRejected);\n return controller;\n}\n","/**\n * Predicates over the pairing/reconnect frame vocabulary the gate admits pre-accept.\n *\n * Issue #2046: these are thin adapters over the OWNER codec in `@robota-sdk/agent-remote-pairing`,\n * which decodes every required field (presence, base64url, length ceiling) rather than only the `t`\n * discriminator this file used to check. A true answer means the narrowed value is well-formed; the\n * browser `ResponderGate` imports the same codec, so one corpus governs both carriers.\n */\n\nimport {\n decodeEnrollFrame,\n decodePairingFrame,\n decodeReconnectFrame,\n type IEnrollFrame,\n type TPairingFrame,\n type TReconnectFrame,\n} from '@robota-sdk/agent-remote-pairing';\n\nexport type { IEnrollFrame };\n\n/** True when a parsed value is a well-formed B3 pairing frame (`pair-nonce` | `pair-confirm`). */\nexport function isPairingFrame(value: unknown): value is TPairingFrame {\n return decodePairingFrame(value).ok;\n}\n\n/** True when a parsed value is a well-formed reconnect frame (`rc-hello` | `rc-host` | `rc-device`). */\nexport function isReconnectFrame(value: unknown): value is TReconnectFrame {\n return decodeReconnectFrame(value).ok;\n}\n\n/** True when a parsed value is a well-formed first-pair enrollment frame carrying a device SPKI. */\nexport function isEnrollFrame(value: unknown): value is IEnrollFrame {\n return decodeEnrollFrame(value).ok;\n}\n","/**\n * Wiring an admitted channel to the session — the step immediately after the gate says yes.\n *\n * \"How session frames travel once admission is settled\" is a different subject from \"what may reach\n * the session at all\", which is the gate's. Split out for the same reason the frame vocabulary and\n * the controllers already were: `pairing-gate.ts` is at its size limit, and the rule there is to\n * split rather than extend.\n *\n * Two carriers, chosen by whether a resume bridge exists. Neither decides anything about admission —\n * by the time either runs, that question is closed.\n */\n\nimport {\n createSessionMessageHandler,\n type ISessionMessageHandlerOptions,\n type SessionResumeBridge,\n} from '@robota-sdk/agent-transport';\n\nimport { createChannelDelivery } from './channel-delivery.js';\n\nimport type { IPairingChannel } from './pairing-gate-options.js';\nimport type { IProtocolSession } from '@robota-sdk/agent-transport';\n\nexport interface IAttachSessionOptions {\n readonly channel: IPairingChannel;\n readonly session: IProtocolSession;\n readonly resumeBridge?: SessionResumeBridge;\n readonly createHandler?: typeof createSessionMessageHandler;\n readonly personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];\n readonly usageReporter?: ISessionMessageHandlerOptions['usageReporter'];\n readonly storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];\n readonly surface?: ISessionMessageHandlerOptions['surface'];\n}\n\nexport interface IAttachedSession {\n /** Where post-accept inbound frames go. */\n readonly onSessionMessage: (data: string) => void;\n /** Detach (bridge) or dispose (handler). The gate calls this on teardown. */\n readonly cleanup: () => void;\n}\n\n/**\n * Attach the admitted channel to the session.\n *\n * `viaReconnect` is threaded through rather than inferred: on a reconnect the bridge must hold live\n * forwarding until the client's `resume` replays the buffered tail, and getting that backwards\n * delivers new frames ahead of the replay — an ordering bug that looks like data loss.\n */\nexport function attachSession(\n options: IAttachSessionOptions,\n viaReconnect: boolean,\n onDeliveryError: (error: Error, event: string) => void,\n): IAttachedSession {\n const bridge = options.resumeBridge;\n if (bridge) {\n // REMOTE-013 E4: route the session through the persistent bridge. Attach this channel as the\n // sink; post-accept inbound frames (incl. resume/ack) go to the bridge; cleanup DETACHES it\n // (never disposes — the bridge is owned by the transport across reconnects).\n bridge.attach((data) => options.channel.send(data), {\n awaitResume: viaReconnect,\n onDeliveryError,\n // ARCH-030: without this the bridge's boundary has no backpressure reading, so the replay\n // path — the burst this budget exists for — runs unbudgeted while every other outbound path\n // on the same connection is guarded. Read at call time; `bufferedAmount` is live.\n pendingBytes: () => options.channel.bufferedAmount,\n });\n return {\n onSessionMessage: (data) => bridge.onClientMessage(data),\n cleanup: () => bridge.detach(),\n };\n }\n\n const create = options.createHandler ?? createSessionMessageHandler;\n // ARCH-030: the gate is the carrier on this branch — its own channel sink, its own failure policy.\n const { onMessage, cleanup } = create({\n session: options.session,\n deliver: createChannelDelivery(options.channel, onDeliveryError),\n ...(options.personalUsageReporter\n ? { personalUsageReporter: options.personalUsageReporter }\n : {}),\n ...(options.usageReporter ? { usageReporter: options.usageReporter } : {}),\n ...(options.storedSessionUsageReporter\n ? { storedSessionUsageReporter: options.storedSessionUsageReporter }\n : {}),\n ...(options.surface ? { surface: options.surface } : {}),\n });\n return { onSessionMessage: onMessage, cleanup };\n}\n","/**\n * Pairing gate for the WebRTC data channel (REMOTE-008 Stage B4-2b; extended for REMOTE-012 Stage E3 TOFU\n * reconnect).\n *\n * The data channel is phase-separated: pre-accept it carries pairing/reconnect frames, post-accept only\n * session messages. An eager subscription feeds {@link PairingGate.onInbound}; the session bridge is not\n * built until acceptance, so no pre-accept peer frame can reach the live session.\n *\n * E3 selects first-pair (`pair-nonce`, then identity-key enrollment) or reconnect (`rc-hello`, pinned\n * identities) from the client's first frame and exposes the session only after mutual acceptance.\n *\n * Fail-closed: a non-admission frame pre-accept is DROPPED; the handshake `result` is the ONLY accept signal;\n * on reject/timeout the channel is closed and no session bridge is ever created; a post-close frame is ignored.\n *\n * When no E3 `reconnect` config is supplied the gate is **exactly** the B4 first-pair-only gate (eager host\n * `pair-nonce`, no enrollment, no reconnect) — preserving existing behavior.\n *\n * SEC-010 adds an optional `local-proof` step between handshake acceptance and session exposure; see\n * `local-peer-proof.ts` for why both of those edges are load-bearing.\n */\n\nimport {\n deriveIdentityId,\n importPublicKey,\n startPairingHandshake,\n type IPairingResult,\n} from '@robota-sdk/agent-remote-pairing';\n\nimport { nextAdmissionStep } from './admission-steps.js';\nimport type {\n IConnectionApproval,\n IConnectionApprovalContext,\n IHostReconnectConfig,\n IPairingChannel,\n IPairingGateOptions,\n} from './pairing-gate-options.js';\n\n// Re-exported so every existing import of these names keeps working: the split moved where they are\n// DECLARED, and moving where they are imported from would be a migration this change is not.\nexport type {\n IConnectionApproval,\n IConnectionApprovalContext,\n IHostReconnectConfig,\n IPairingChannel,\n IPairingGateOptions,\n};\nimport { judgeHandoffGrant } from './handoff-grant-gate.js';\nimport { judgeLocalProof } from './local-peer-proof.js';\nimport { pairingChannel } from './pairing-channel-lifecycle.js';\nimport { startFirstPairController, startReconnectController } from './pairing-controllers.js';\nimport { isEnrollFrame, isPairingFrame, isReconnectFrame } from './pairing-frames.js';\nimport { attachSession } from './session-attachment.js';\n\nimport type { IEnrollFrame } from './pairing-frames.js';\n\n/** How much a peer may say while the operator decides: a client's opening requests, not a stream. */\nconst HELD_FRAMES_MAX = 32;\nconst HELD_CHARS_MAX = 256 * 1024;\n\ntype TGateState =\n | 'awaiting-mode'\n | 'pairing'\n | 'enrolling'\n | 'reconnecting'\n | 'local-proof'\n | 'handoff-grant'\n | 'operator-approval'\n | 'accepted'\n | 'closed';\n\nexport class PairingGate {\n private state: TGateState;\n /** Session message router — built ONLY on accept (nothing reaches the session before). */\n private onSessionMessage?: (data: string) => void;\n private handlerCleanup?: () => void;\n private pairingController?: ReturnType<typeof startPairingHandshake>;\n private reconnectController?: ReturnType<typeof startReconnectController>;\n /** The first-pair result, held so it can be surfaced on accept (E4 uses its sessionKey). */\n private pendingResult?: IPairingResult;\n /** Held across the local-proof step so the reconnect ordering rule survives the detour. */\n private pendingViaReconnect = false;\n /** The device the handshake proved, for the operator's question. */\n private pendingDeviceId?: string;\n /** A first-pair device key, pinned only once every step has admitted the channel. */\n private pendingEnrollment?: { readonly deviceId: string; readonly spki: string };\n /**\n * Session frames the peer sent while the operator was deciding. The peer's own handshake has\n * already accepted, so it starts talking (a resume, a history request) at once; dropping those\n * would leave an approved device waiting on answers to questions the host never saw. They reach\n * the session only after a yes, and a peer that sends more than a bounded amount is refused.\n */\n private heldFrames: string[] = [];\n private heldChars = 0;\n /** Withdraws the question put to the operator when the connection goes away first. */\n private approvalAbort?: AbortController;\n\n constructor(private readonly options: IPairingGateOptions) {\n if (options.reconnect) {\n // E3 mode: stay reactive — the client's first frame selects first-pair vs reconnect.\n this.state = 'awaiting-mode';\n } else {\n // Legacy B4 mode: eagerly start the first-pair handshake (host sends pair-nonce immediately).\n this.state = 'pairing';\n this.startFirstPair();\n }\n }\n\n /**\n * Route one inbound channel frame. Pre-accept: admission frames → the active controller, everything else\n * DROPPED. Post-accept: session messages → the session bridge. Post-close: ignored.\n */\n onInbound(data: string): void {\n if (this.state === 'closed') return;\n if (this.state === 'accepted') {\n this.onSessionMessage?.(data);\n return;\n }\n if (this.state === 'operator-approval') {\n this.holdForApproval(data);\n return;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(data);\n } catch {\n return; // undecodable pre-accept frame → drop\n }\n\n if (this.state === 'awaiting-mode') {\n // First frame selects the mode (E3).\n if (isReconnectFrame(parsed) && parsed.t === 'rc-hello') {\n this.state = 'reconnecting';\n this.startReconnect();\n this.reconnectController?.onFrame(parsed);\n } else if (isPairingFrame(parsed) && parsed.t === 'pair-nonce') {\n this.state = 'pairing';\n this.startFirstPair();\n this.pairingController?.onFrame(parsed);\n }\n // anything else pre-mode → drop\n return;\n }\n\n if (this.state === 'pairing') {\n if (isPairingFrame(parsed)) this.pairingController?.onFrame(parsed);\n return;\n }\n\n if (this.state === 'reconnecting') {\n if (isReconnectFrame(parsed)) this.reconnectController?.onFrame(parsed);\n return;\n }\n\n if (this.state === 'local-proof') {\n const admission = judgeLocalProof(parsed, this.options.localPeer);\n this.options.localPeer?.onAdmission?.(admission);\n if (admission.admitted) this.accept(this.pendingResult, this.pendingViaReconnect);\n else this.rejectAndClose();\n return;\n }\n\n if (this.state === 'handoff-grant') {\n // Verifying a signature is async, and this handler is not. The promise is consumed here rather\n // than returned so a rejection cannot escape into the channel's message subscription — an\n // unhandled rejection would leave the gate parked in this state with the channel open, which\n // is a hang. `judgeHandoffGrant` already converts a throw into a refusal; this is the second\n // layer, because a gate that can hang is a gate that fails open by waiting.\n void this.judgeGrantFrame(parsed);\n return;\n }\n\n if (this.state === 'enrolling') {\n // Awaiting the peer's identity public key to pin, then expose the session.\n if (isEnrollFrame(parsed)) this.completeEnrollment(parsed.spki);\n return;\n }\n }\n\n /** Tear down: cleanup the session bridge (if built) and mark closed. Idempotent. */\n cleanup(): void {\n this.state = 'closed';\n this.withdrawApproval();\n this.handlerCleanup?.();\n this.handlerCleanup = undefined;\n this.onSessionMessage = undefined;\n }\n\n /**\n * The channel closed. Before acceptance that is a refusal: nobody is left to admit, so a question\n * still open with the operator is withdrawn and a later answer admits nothing. After acceptance the\n * owning transport handles the drop.\n */\n onChannelClosed(): void {\n if (this.state === 'accepted' || this.state === 'closed') return;\n this.rejectAndClose();\n }\n\n private withdrawApproval(): void {\n this.heldFrames = [];\n this.heldChars = 0;\n this.approvalAbort?.abort();\n this.approvalAbort = undefined;\n }\n\n private startFirstPair(): void {\n // `IPairingGateOptions` already carries every field `IControllerContext` asks for, so it is\n // passed straight through — projecting it field by field would be a copy to keep in step.\n this.pairingController = startFirstPairController(\n this.options,\n this.options.secret,\n this.options.role,\n (result) => this.onFirstPairAccepted(result),\n () => this.rejectAndClose(),\n this.options.startHandshake ?? startPairingHandshake,\n );\n }\n\n private startReconnect(): void {\n const cfg = this.options.reconnect;\n if (!cfg) {\n this.rejectAndClose();\n return;\n }\n this.reconnectController = startReconnectController(\n this.options,\n cfg,\n // reconnect → hold live forwarding until the client's resume replays\n (result) => {\n this.pendingDeviceId = result.deviceId;\n this.accept(undefined, true);\n },\n () => this.rejectAndClose(),\n );\n }\n\n /** B3 handshake accepted. Without E3: expose immediately. With E3: run first-pair enrollment first. */\n private onFirstPairAccepted(result: IPairingResult): void {\n if (this.state !== 'pairing') return;\n this.pendingResult = result;\n const cfg = this.options.reconnect;\n if (!cfg) {\n this.accept(result);\n return;\n }\n // E3 enrollment: advertise the host public key; the peer's `enroll-key` completes it (then expose).\n this.state = 'enrolling';\n pairingChannel.send(\n this.options.channel,\n JSON.stringify({ t: 'enroll-key', spki: cfg.hostPublicSpki } satisfies IEnrollFrame),\n );\n }\n\n private completeEnrollment(deviceSpki: string): void {\n if (this.state !== 'enrolling') return;\n const cfg = this.options.reconnect;\n if (!cfg) {\n this.rejectAndClose();\n return;\n }\n void (async (): Promise<void> => {\n try {\n // Validate the SPKI parses as a public key before pinning (fail closed on garbage).\n await importPublicKey(deviceSpki);\n const deviceId = await deriveIdentityId(deviceSpki);\n if (this.state !== 'enrolling') return;\n this.pendingDeviceId = deviceId;\n this.pendingEnrollment = { deviceId, spki: deviceSpki };\n this.accept(this.pendingResult);\n } catch {\n this.rejectAndClose();\n }\n })();\n }\n\n private accept(result?: IPairingResult, viaReconnect = false): void {\n if (this.state === 'closed' || this.state === 'accepted') return;\n // The steps this channel still owes. Demanded here rather than earlier because the handshake\n // must already have bound the channel, and before anything below because that is where the\n // session becomes reachable. The ordering argument lives in `admission-steps.ts`.\n const owed = nextAdmissionStep(this.options, this.state);\n if (owed !== null) {\n this.pendingResult = result ?? this.pendingResult;\n this.pendingViaReconnect = viaReconnect;\n this.state = owed;\n if (owed === 'operator-approval') void this.askOperator();\n return;\n }\n // Pin a first-pair device only now: a device a later step refused is not remembered.\n const enrollment = this.pendingEnrollment;\n this.pendingEnrollment = undefined;\n if (enrollment !== undefined) {\n try {\n this.options.reconnect?.onEnroll(enrollment.deviceId, enrollment.spki);\n } catch {\n this.rejectAndClose();\n return;\n }\n }\n const attached = attachSession(this.options, viaReconnect, (error, event) =>\n this.handleSessionDeliveryError(error, event),\n );\n this.onSessionMessage = attached.onSessionMessage;\n this.handlerCleanup = attached.cleanup;\n this.state = 'accepted';\n this.options.onAccept?.(result);\n const held = this.heldFrames;\n this.heldFrames = [];\n this.heldChars = 0;\n for (const frame of held) {\n if (this.state !== 'accepted') return;\n this.onSessionMessage?.(frame);\n }\n }\n\n private holdForApproval(data: string): void {\n this.heldChars += data.length;\n this.heldFrames.push(data);\n if (this.heldFrames.length > HELD_FRAMES_MAX || this.heldChars > HELD_CHARS_MAX) {\n this.rejectAndClose();\n }\n }\n\n /**\n * The `operator-approval` state: ask, then admit only on an explicit yes. A failure to ask is a no,\n * and an answer that arrives after the channel was torn down admits nothing.\n */\n private async askOperator(): Promise<void> {\n const abort = new AbortController();\n this.approvalAbort = abort;\n const context: IConnectionApprovalContext = {\n ...(this.pendingDeviceId !== undefined ? { deviceId: this.pendingDeviceId } : {}),\n viaReconnect: this.pendingViaReconnect,\n signal: abort.signal,\n };\n let allowed = false;\n try {\n allowed = (await this.options.connectionApproval?.approve(context)) === true;\n } catch {\n allowed = false;\n }\n if (this.state !== 'operator-approval' || abort.signal.aborted) return;\n this.approvalAbort = undefined;\n if (allowed) this.accept(this.pendingResult, this.pendingViaReconnect);\n else this.rejectAndClose();\n }\n\n /** The async half of the `handoff-grant` state, kept off the synchronous message path. */\n private async judgeGrantFrame(parsed: unknown): Promise<void> {\n const admission = await judgeHandoffGrant(parsed, this.options.handoffGrant);\n // A channel torn down while the signature was being checked must not be re-accepted by a verdict\n // that arrives afterwards. Checked after the await, because that is the window it exists for.\n if (this.state !== 'handoff-grant') return;\n this.options.handoffGrant?.onAdmission?.(admission);\n if (admission.admitted) this.accept(this.pendingResult, this.pendingViaReconnect);\n else this.rejectAndClose();\n }\n\n private rejectAndClose(): void {\n if (this.state === 'closed') return;\n this.state = 'closed';\n this.withdrawApproval();\n pairingChannel.close(this.options.channel);\n this.options.onReject?.();\n }\n\n private handleSessionDeliveryError(error: Error, event: string): void {\n if (this.state === 'closed') return;\n this.state = 'closed';\n this.handlerCleanup?.();\n this.handlerCleanup = undefined;\n this.onSessionMessage = undefined;\n pairingChannel.reportDeliveryError(this.options.onDeliveryError, error, event);\n pairingChannel.close(this.options.channel);\n }\n}\n","import type { ITransportLifecycleError } from '@robota-sdk/agent-interface-transport';\n\nexport function createTransportLifecycleError(\n code: ITransportLifecycleError['code'],\n): ITransportLifecycleError {\n return Object.assign(new Error(`WebRtcTransport ${code}.`), {\n name: 'TransportLifecycleError' as const,\n code,\n transportName: 'webrtc',\n });\n}\n","import type { RTCDataChannel } from 'werift';\n\ninterface IWebRtcDeliveryLifecycleOptions {\n readonly cleanup: () => void;\n readonly onDropped: () => void;\n readonly onDeliveryError: (error: Error, event: string) => void;\n}\n\n/** Owns carrier-drop state so delivery failures cannot leak into committed session operations. */\nexport class WebRtcDeliveryLifecycle {\n private generation = 0;\n private paired = false;\n private dropped = false;\n\n constructor(private readonly options: IWebRtcDeliveryLifecycleOptions) {}\n\n reset(generation: number): void {\n this.generation = generation;\n this.paired = false;\n this.dropped = false;\n }\n\n accept(generation: number): void {\n if (generation === this.generation) this.paired = true;\n }\n\n handleDrop(generation: number): void {\n if (generation !== this.generation || !this.paired || this.dropped) return;\n this.dropped = true;\n this.options.cleanup();\n this.options.onDropped();\n }\n\n handleFailure(channel: RTCDataChannel, generation: number, error: Error, event: string): void {\n if (generation !== this.generation || this.dropped) return;\n const notifyDrop = this.paired;\n if (notifyDrop) this.dropped = true;\n try {\n this.options.onDeliveryError(error, event);\n } catch {\n // Observer failure cannot interrupt carrier cleanup or a committed session operation.\n }\n this.options.cleanup();\n try {\n channel.close();\n } catch {\n // already closing/closed\n }\n if (notifyDrop) this.options.onDropped();\n }\n}\n","import { createSessionMessageHandler } from '@robota-sdk/agent-transport';\nimport { resolveAdmission } from '@robota-sdk/agent-transport/node';\nimport {\n extractDtlsFingerprint,\n extractDtlsFingerprintAttribute,\n} from '@robota-sdk/agent-remote-pairing';\nimport type { IConfigurableTransport } from '@robota-sdk/agent-interface-transport';\nimport type { RTCDataChannel, RTCPeerConnection } from 'werift';\n\nimport type { IProtocolSession } from '@robota-sdk/agent-transport';\n\nimport { createChannelDelivery } from './channel-delivery.js';\nimport { whenRemoteCertificateVerified } from './negotiated-certificate.js';\nimport { loadWerift } from './werift-loader.js';\nimport { PairingGate } from './pairing-gate.js';\nimport { createTransportLifecycleError } from './transport-lifecycle-error.js';\nimport { WebRtcDeliveryLifecycle } from './webrtc-delivery-lifecycle.js';\nimport type { IWebRtcTransportOptions } from './webrtc-transport-options.js';\n\n/** Frames a peer may send before the gate exists; the pairing handshake needs only a few. */\nconst MAX_PENDING_FRAMES = 16;\n\n/**\n * WebRTC P2P transport (REMOTE-001/002): carries an `IProtocolSession` over an `RTCDataChannel` using the\n * SAME transport-neutral session bridge as the WebSocket transport (`createSessionMessageHandler` from\n * `@robota-sdk/agent-transport`). The host is the offerer: it creates the data channel + offer, and on\n * data-channel open wires the handler. **Stage A: `defaultEnabled: false`, no pairing/auth** — the signaling\n * client is injected and can be an in-memory loopback for tests.\n */\nexport class WebRtcTransport implements IConfigurableTransport<IProtocolSession> {\n public readonly name = 'webrtc';\n public readonly lifecycle = Object.freeze({ kind: 'service' as const });\n public readonly defaultEnabled = false;\n public readonly optionsSchema = {} as const;\n\n private session?: IProtocolSession;\n private peer?: RTCPeerConnection;\n private unsubscribeSignal?: () => void;\n private cleanupHandler?: () => void;\n /** Invalidates pending async startup work and scopes pairing/drop state to one start generation. */\n private generation = 0;\n /** Local DTLS fingerprint captured for pairing channel binding. */\n private localFingerprint?: string;\n /** Pairing gate for the current channel. */\n private pairingGate?: PairingGate;\n /** Whether this start generation has taken its one answer; any later answer is ignored. */\n private answered = false;\n /** Pre-gate channel frames, replayed into the gate once it exists. Bounded. */\n private pendingFrames: string[] = [];\n private stopAwaitingCertificate?: () => void;\n private readonly deliveryLifecycle: WebRtcDeliveryLifecycle;\n\n public constructor(private readonly options: IWebRtcTransportOptions) {\n this.deliveryLifecycle = new WebRtcDeliveryLifecycle({\n cleanup: () => {\n this.cleanupHandler?.();\n this.pairingGate?.cleanup();\n },\n onDropped: () => this.options.onDropped?.(),\n onDeliveryError: (error, event) => this.options.onDeliveryError?.(error, event),\n });\n // A pairing secret and explicit open admission are contradictory, so fail before signaling.\n if (this.options.secret && this.options.open === true) {\n throw new Error(\n 'WebRtcTransport: `secret` and `open: true` are contradictory. A pairing secret gates the ' +\n 'data channel; `open` runs without a gate. Pass one.',\n );\n }\n if (this.options.secret === undefined || this.options.secret === '') {\n if (this.options.open !== true) {\n throw new Error(\n 'WebRtcTransport: no pairing `secret` and no explicit `open`. Pass a `secret` to gate the ' +\n 'data channel, or `{ open: true, openReason: \"…\" }` to run without pairing on purpose.',\n );\n }\n // WebRTC has no bearer credential; use the shared seam only to validate the open reason.\n void resolveAdmission({\n open: true,\n ...(this.options.openReason !== undefined ? { openReason: this.options.openReason } : {}),\n });\n }\n }\n\n public validateOptions(): boolean {\n return true;\n }\n\n public attach(session: IProtocolSession): void {\n this.session = session;\n }\n private createPeer(): RTCPeerConnection {\n const { RTCPeerConnection } = (this.options.loadWerift ?? loadWerift)();\n const config: {\n iceServers?: { urls: string; username?: string; credential?: string }[];\n iceTransportPolicy?: 'all' | 'relay';\n } = {};\n if (this.options.iceServers)\n config.iceServers = this.options.iceServers.map((server) => ({ ...server }));\n if (this.options.forceTurn) config.iceTransportPolicy = 'relay';\n return new RTCPeerConnection(Object.keys(config).length > 0 ? config : undefined);\n }\n\n private wireSignaling(\n peer: RTCPeerConnection,\n channel: RTCDataChannel,\n session: IProtocolSession,\n generation: number,\n ): void {\n const signaling = this.options.signaling;\n peer.onIceCandidate.subscribe((candidate) => {\n if (candidate && generation === this.generation && peer === this.peer) {\n signaling.send({ kind: 'ice', data: candidate.toJSON() });\n }\n });\n let signalChain: Promise<void> = Promise.resolve();\n this.unsubscribeSignal = signaling.onSignal((message) => {\n if (generation !== this.generation) return;\n signalChain = signalChain.then(async () => {\n if (generation !== this.generation || peer !== this.peer) return;\n if (message.kind === 'answer') {\n // One answer per start. A later answer would add fingerprints the DTLS layer then also accepts.\n if (this.answered) return;\n this.answered = true;\n const algorithm = this.remoteFingerprintAlgorithm(channel, message.data);\n if (this.options.secret && algorithm === undefined) return;\n await peer.setRemoteDescription(\n message.data as Parameters<typeof peer.setRemoteDescription>[0],\n );\n if (algorithm !== undefined)\n this.awaitVerifiedCertificate(peer, channel, session, algorithm, generation);\n } else if (message.kind === 'ice') {\n await peer.addIceCandidate(message.data as Parameters<typeof peer.addIceCandidate>[0]);\n }\n });\n });\n }\n\n private async requireCurrentPeer(peer: RTCPeerConnection, generation: number): Promise<void> {\n if (generation === this.generation && this.peer === peer) return;\n await peer.close();\n throw new Error('WebRtcTransport startup was stopped.');\n }\n\n public async start(): Promise<void> {\n const session = this.session;\n if (!session) throw createTransportLifecycleError('not-attached');\n if (this.peer) throw createTransportLifecycleError('already-started');\n const generation = ++this.generation;\n this.deliveryLifecycle.reset(generation);\n this.answered = false;\n this.pendingFrames = [];\n\n const peer = this.createPeer();\n this.peer = peer;\n const signaling = this.options.signaling;\n const channel = peer.createDataChannel('robota-session');\n this.wireChannel(channel, session, generation);\n this.wireSignaling(peer, channel, session, generation);\n\n const offer = await peer.createOffer();\n await this.requireCurrentPeer(peer, generation);\n await peer.setLocalDescription(offer);\n await this.requireCurrentPeer(peer, generation);\n // Capture the local DTLS fingerprint for the pairing channel-binding (offer SDP).\n if (this.options.secret && peer.localDescription) {\n this.localFingerprint = extractDtlsFingerprint(peer.localDescription.sdp);\n }\n signaling.send({ kind: 'offer', data: peer.localDescription });\n }\n\n /**\n * With a pairing secret, the answer must advertise exactly one DTLS fingerprint; its algorithm is the one the\n * verified certificate is hashed with. A refused answer fails pairing and closes the channel.\n */\n private remoteFingerprintAlgorithm(channel: RTCDataChannel, answer: unknown): string | undefined {\n if (!this.options.secret) return undefined;\n const sdp = (answer as { sdp?: unknown }).sdp;\n try {\n if (typeof sdp !== 'string') throw new Error('answer carries no SDP');\n return extractDtlsFingerprintAttribute(sdp).algorithm;\n } catch {\n this.failPairing(channel);\n return undefined;\n }\n }\n\n private failPairing(channel: RTCDataChannel): void {\n try {\n void channel.close();\n } catch {\n /* already closing */\n }\n this.options.onPairingFailed?.();\n }\n\n /** Build the pairing gate once the DTLS layer has verified the remote certificate. */\n private awaitVerifiedCertificate(\n peer: RTCPeerConnection,\n channel: RTCDataChannel,\n session: IProtocolSession,\n algorithm: string,\n generation: number,\n ): void {\n this.stopAwaitingCertificate = whenRemoteCertificateVerified(\n peer,\n algorithm,\n (remoteFingerprint) => {\n if (generation !== this.generation) return;\n this.startPairing(channel, session, remoteFingerprint, generation);\n },\n () => {\n if (generation === this.generation) this.failPairing(channel);\n },\n );\n }\n\n private startPairing(\n channel: RTCDataChannel,\n session: IProtocolSession,\n remoteFingerprint: string,\n generation: number,\n ): void {\n const secret = this.options.secret;\n if (!secret || !this.localFingerprint) return;\n this.pairingGate = new PairingGate({\n channel: { send: (d) => channel.send(d), close: () => void channel.close() },\n session,\n secret,\n role: 'initiator',\n localFingerprint: this.localFingerprint,\n remoteFingerprint,\n onAccept: (result) => {\n if (generation !== this.generation) return;\n this.deliveryLifecycle.accept(generation);\n this.options.onPaired?.(result);\n },\n ...(this.options.onPairingFailed ? { onReject: this.options.onPairingFailed } : {}),\n ...(this.options.reconnect ? { reconnect: this.options.reconnect } : {}),\n ...(this.options.localPeer ? { localPeer: this.options.localPeer } : {}),\n ...(this.options.connectionApproval\n ? { connectionApproval: this.options.connectionApproval }\n : {}),\n ...(this.options.resumeBridge ? { resumeBridge: this.options.resumeBridge } : {}),\n ...(this.options.personalUsageReporter\n ? { personalUsageReporter: this.options.personalUsageReporter }\n : {}),\n ...(this.options.usageReporter ? { usageReporter: this.options.usageReporter } : {}),\n ...(this.options.storedSessionUsageReporter\n ? { storedSessionUsageReporter: this.options.storedSessionUsageReporter }\n : {}),\n surface: 'remote',\n onDeliveryError: (error, event) =>\n this.deliveryLifecycle.handleFailure(channel, generation, error, event),\n });\n const gate = this.pairingGate;\n for (const frame of this.pendingFrames.splice(0)) gate.onInbound(frame);\n }\n\n private wireChannel(\n channel: RTCDataChannel,\n session: IProtocolSession,\n generation: number,\n ): void {\n // Subscribe eagerly: werift does not buffer a remote's first frame before a listener exists.\n // With a secret the gate drops pre-accept non-pairing frames; otherwise the session is exposed directly.\n if (this.options.secret) {\n channel.onMessage.subscribe((data) => {\n if (generation !== this.generation) return;\n const frame = typeof data === 'string' ? data : data.toString();\n if (this.pairingGate) this.pairingGate.onInbound(frame);\n else if (this.pendingFrames.length < MAX_PENDING_FRAMES) this.pendingFrames.push(frame);\n });\n // A post-accept close detaches the resume bridge and starts reconnect without ending the session.\n channel.stateChanged.subscribe((state) => {\n if (generation !== this.generation) return;\n if (state === 'closed' || state === 'closing') {\n // Before acceptance the gate must hear it too: a question still open with the operator is\n // about a connection that no longer exists.\n this.pairingGate?.onChannelClosed();\n this.deliveryLifecycle.handleDrop(generation);\n }\n });\n this.cleanupHandler = () => this.pairingGate?.cleanup();\n return;\n }\n\n // ARCH-030: the transport is the carrier on the no-secret branch — its own sink, its own lifecycle.\n const { onMessage, cleanup } = createSessionMessageHandler({\n session,\n deliver: createChannelDelivery(channel, (error, event) =>\n this.deliveryLifecycle.handleFailure(channel, generation, error, event),\n ),\n ...(this.options.personalUsageReporter\n ? { personalUsageReporter: this.options.personalUsageReporter }\n : {}),\n ...(this.options.usageReporter ? { usageReporter: this.options.usageReporter } : {}),\n ...(this.options.storedSessionUsageReporter\n ? { storedSessionUsageReporter: this.options.storedSessionUsageReporter }\n : {}),\n surface: 'remote',\n });\n this.cleanupHandler = cleanup;\n channel.onMessage.subscribe((data) => {\n if (generation !== this.generation) return;\n onMessage(typeof data === 'string' ? data : data.toString());\n });\n }\n\n public async stop(): Promise<void> {\n this.generation += 1;\n this.cleanupHandler?.();\n this.unsubscribeSignal?.();\n this.cleanupHandler = undefined;\n this.unsubscribeSignal = undefined;\n this.pairingGate = undefined;\n this.localFingerprint = undefined;\n this.answered = false;\n this.pendingFrames = [];\n this.stopAwaitingCertificate?.();\n this.stopAwaitingCertificate = undefined;\n this.deliveryLifecycle.reset(this.generation);\n if (this.peer) {\n await this.peer.close();\n this.peer = undefined;\n }\n this.session = undefined;\n }\n}\n","/**\n * Signaling port (REMOTE-002). The WebRTC transport is decoupled from any concrete signaling server via this\n * port: it exchanges opaque SDP/ICE blobs by rendezvous id. The port carries **only** SDP offers/answers + ICE\n * candidates — never session content — so a signaling server (or the in-memory test pair) is content-blind.\n */\n\n/** The kinds of signal a peer exchanges through the relay. */\nexport type TSignalKind = 'offer' | 'answer' | 'ice';\n\n/** An opaque signaling message relayed by rendezvous id. `data` is an SDP description or an ICE candidate. */\nexport interface ISignalMessage {\n readonly kind: TSignalKind;\n /** Opaque payload (serialized SDP or ICE candidate). The signaling layer never inspects it. */\n readonly data: unknown;\n}\n\n/** Port a WebRTC peer uses to reach its counterpart at a rendezvous id. */\nexport interface ISignalingClient {\n /** Send a signal to the counterpart at the rendezvous. */\n send(message: ISignalMessage): void;\n /** Subscribe to signals from the counterpart. Returns an unsubscribe function. */\n onSignal(handler: (message: ISignalMessage) => void): () => void;\n /** Tear down the signaling channel. */\n close(): void;\n}\n\n/**\n * An in-process pair of {@link ISignalingClient}s wired directly to each other — for tests + loopback only\n * (no server, no network). Each side's `send` is delivered to the other side's handlers on a microtask.\n */\nexport function createInMemorySignalingPair(): [ISignalingClient, ISignalingClient] {\n const handlersA: ((m: ISignalMessage) => void)[] = [];\n const handlersB: ((m: ISignalMessage) => void)[] = [];\n let open = true;\n\n function make(\n ownHandlers: ((m: ISignalMessage) => void)[],\n peerHandlers: ((m: ISignalMessage) => void)[],\n ): ISignalingClient {\n return {\n send(message) {\n if (!open) return;\n queueMicrotask(() => {\n for (const h of peerHandlers) h(message);\n });\n },\n onSignal(handler) {\n ownHandlers.push(handler);\n return () => {\n const i = ownHandlers.indexOf(handler);\n if (i >= 0) ownHandlers.splice(i, 1);\n };\n },\n close() {\n open = false;\n ownHandlers.length = 0;\n },\n };\n }\n\n return [make(handlersA, handlersB), make(handlersB, handlersA)];\n}\n","/**\n * Production `ISignalingClient` over a WebSocket connection to the `@robota-sdk/remote-signaling` relay\n * (REMOTE-004 Stage B2). This is the **Node host-side** client (the browser remote client, Stage D, implements\n * `ISignalingClient` on the native `WebSocket`).\n *\n * On open it `join`s the rendezvous and flushes any signals produced before the socket opened (the offer is\n * created in `WebRtcTransport.start()`, which can run before the socket connects — buffering avoids dropping it).\n * Relay `error` frames, socket errors, and a close-before-join are surfaced through an explicit `onError`\n * callback — **never a silent degrade** (no-fallback, mirroring `loadReplayProvider`/`loadWerift`). The relay is\n * content-blind: this client only ever emits `join` + `signal` frames and only ever consumes `joined` / `signal`\n * / `error` frames.\n */\nimport WebSocket from 'ws';\n\nimport type { ISignalingClient, ISignalMessage, TSignalKind } from './signaling.js';\n\n/** A minimal WebSocket-like surface — so tests can inject a fake without a real socket. */\nexport interface IWebSocketLike {\n send(data: string): void;\n close(): void;\n readyState: number;\n on(event: 'open' | 'message' | 'error' | 'close', handler: (arg: unknown) => void): void;\n}\n\nexport interface IWsSignalingClientOptions {\n /** Relay URL, e.g. `ws://127.0.0.1:1234`. */\n readonly url: string;\n /** Rendezvous id to join. */\n readonly rendezvous: string;\n /**\n * Explicit error sink. Called on a relay `error` frame, a socket error, or a close-before-join — the signaling\n * channel cannot function and the caller must know (no silent degrade).\n */\n readonly onError?: (error: Error) => void;\n /** Called once the relay confirms the rendezvous `join` (the `joined` frame). */\n readonly onReady?: () => void;\n /** Injectable socket factory (defaults to a real `ws` WebSocket) — for tests. */\n readonly createSocket?: (url: string) => IWebSocketLike;\n}\n\nconst SIGNAL_KINDS: ReadonlySet<string> = new Set<TSignalKind>(['offer', 'answer', 'ice']);\nconst WS_OPEN = 1;\n\nfunction defaultCreateSocket(url: string): IWebSocketLike {\n return new WebSocket(url) as unknown as IWebSocketLike;\n}\n\nexport class WsSignalingClient implements ISignalingClient {\n private readonly socket: IWebSocketLike;\n private readonly handlers: ((message: ISignalMessage) => void)[] = [];\n private readonly outbox: ISignalMessage[] = [];\n private joined = false;\n private closed = false;\n\n public constructor(private readonly options: IWsSignalingClientOptions) {\n const factory = options.createSocket ?? defaultCreateSocket;\n this.socket = factory(options.url);\n this.socket.on('open', () => this.handleOpen());\n this.socket.on('message', (data) => this.handleMessage(data));\n this.socket.on('error', (err) =>\n this.fail(err instanceof Error ? err : new Error(String(err))),\n );\n this.socket.on('close', () => this.handleClose());\n }\n\n private handleOpen(): void {\n this.socket.send(JSON.stringify({ type: 'join', rendezvous: this.options.rendezvous }));\n // Flush any signals produced before the socket opened (e.g. the offer from WebRtcTransport.start()).\n for (const message of this.outbox) this.writeSignal(message);\n this.outbox.length = 0;\n }\n\n private handleMessage(data: unknown): void {\n let frame: unknown;\n try {\n frame = JSON.parse(typeof data === 'string' ? data : String(data));\n } catch {\n return; // a non-JSON frame from the relay is ignored (the relay only emits JSON)\n }\n if (typeof frame !== 'object' || frame === null) return;\n const record = frame as Record<string, unknown>;\n if (record.type === 'joined') {\n this.joined = true;\n this.options.onReady?.();\n return;\n }\n if (record.type === 'error') {\n this.fail(new Error(`signaling relay rejected the connection: ${String(record.reason)}`));\n return;\n }\n if (\n record.type === 'signal' &&\n typeof record.kind === 'string' &&\n SIGNAL_KINDS.has(record.kind)\n ) {\n const message: ISignalMessage = { kind: record.kind as TSignalKind, data: record.data };\n for (const handler of this.handlers) handler(message);\n }\n }\n\n private handleClose(): void {\n if (this.closed) return; // an intentional close() is not an error\n if (!this.joined) {\n this.fail(new Error('signaling socket closed before the rendezvous was joined'));\n }\n }\n\n private fail(error: Error): void {\n this.options.onError?.(error);\n }\n\n private writeSignal(message: ISignalMessage): void {\n this.socket.send(JSON.stringify({ type: 'signal', kind: message.kind, data: message.data }));\n }\n\n public send(message: ISignalMessage): void {\n if (this.closed) return;\n if (this.socket.readyState === WS_OPEN) {\n this.writeSignal(message);\n } else {\n this.outbox.push(message); // buffer until open (see handleOpen)\n }\n }\n\n public onSignal(handler: (message: ISignalMessage) => void): () => void {\n this.handlers.push(handler);\n return () => {\n const index = this.handlers.indexOf(handler);\n if (index >= 0) this.handlers.splice(index, 1);\n };\n }\n\n public close(): void {\n this.closed = true;\n this.handlers.length = 0;\n this.outbox.length = 0;\n this.socket.close();\n }\n}\n"],"mappings":"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"}
|