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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +694 -0
  2. package/LICENSE +661 -0
  3. package/README.md +111 -0
  4. package/dist/node/index.cjs +1 -0
  5. package/dist/node/index.d.cts +1650 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1650 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +2 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +93 -0
  12. package/src/__tests__/admission-secure-by-default.test.ts +63 -0
  13. package/src/__tests__/admission-steps.test.ts +46 -0
  14. package/src/__tests__/device-mesh-relay.test.ts +441 -0
  15. package/src/__tests__/device-mesh.test.ts +682 -0
  16. package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
  17. package/src/__tests__/enrollment-link.test.ts +103 -0
  18. package/src/__tests__/fake-datachannel.ts +83 -0
  19. package/src/__tests__/handoff-grant-gate.test.ts +403 -0
  20. package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
  21. package/src/__tests__/mesh-admission-contract.test.ts +38 -0
  22. package/src/__tests__/mesh-discovery.test.ts +820 -0
  23. package/src/__tests__/mesh-fixtures.ts +148 -0
  24. package/src/__tests__/mesh-internet.test.ts +942 -0
  25. package/src/__tests__/mesh-turn-relay.test.ts +161 -0
  26. package/src/__tests__/negotiated-certificate.test.ts +76 -0
  27. package/src/__tests__/nostr-source-condition.test.ts +27 -0
  28. package/src/__tests__/operator-approval-gate.test.ts +308 -0
  29. package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
  30. package/src/__tests__/pairing-e2e.test.ts +237 -0
  31. package/src/__tests__/pairing-gate-e3.test.ts +194 -0
  32. package/src/__tests__/pairing-gate-e4.test.ts +110 -0
  33. package/src/__tests__/pairing-gate.test.ts +296 -0
  34. package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
  35. package/src/__tests__/rtc-channel-close.test.ts +52 -0
  36. package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
  37. package/src/__tests__/session-attachment-app-view.test.ts +66 -0
  38. package/src/__tests__/session-attachment-budget.test.ts +136 -0
  39. package/src/__tests__/turn-server-bind.test.ts +59 -0
  40. package/src/__tests__/turn-server.test.ts +335 -0
  41. package/src/__tests__/turn-test-client.ts +256 -0
  42. package/src/__tests__/webrtc-implementation.test.ts +83 -0
  43. package/src/__tests__/webrtc-transport.test.ts +349 -0
  44. package/src/__tests__/ws-mesh-relay-client.test.ts +95 -0
  45. package/src/__tests__/ws-signaling-client.test.ts +108 -0
  46. package/src/admission-steps.ts +55 -0
  47. package/src/channel-delivery.ts +44 -0
  48. package/src/datachannel-loader.ts +86 -0
  49. package/src/device-mesh-node.ts +1019 -0
  50. package/src/discovering-mesh-relay.ts +675 -0
  51. package/src/enrollment-link.ts +465 -0
  52. package/src/handoff-grant-gate.ts +186 -0
  53. package/src/index.ts +139 -0
  54. package/src/local-peer-proof.ts +135 -0
  55. package/src/mainline-dht-store.ts +174 -0
  56. package/src/mesh-dht.ts +446 -0
  57. package/src/mesh-discovery.ts +87 -0
  58. package/src/mesh-item-store.ts +287 -0
  59. package/src/mesh-lan-listener.ts +193 -0
  60. package/src/mesh-mdns.ts +406 -0
  61. package/src/mesh-peer-link.ts +600 -0
  62. package/src/mesh-public-relays.ts +25 -0
  63. package/src/mesh-records.ts +357 -0
  64. package/src/mesh-relay.ts +98 -0
  65. package/src/mesh-signal.ts +116 -0
  66. package/src/mesh-turn-relay.ts +238 -0
  67. package/src/negotiated-certificate.ts +72 -0
  68. package/src/nostr-mesh-relay.ts +391 -0
  69. package/src/pairing-channel-lifecycle.ts +31 -0
  70. package/src/pairing-controllers.ts +81 -0
  71. package/src/pairing-frames.ts +34 -0
  72. package/src/pairing-gate-options.ts +133 -0
  73. package/src/pairing-gate.ts +397 -0
  74. package/src/rtc-peer.ts +429 -0
  75. package/src/session-attachment.ts +96 -0
  76. package/src/signaling.ts +62 -0
  77. package/src/stun-message.ts +341 -0
  78. package/src/transport-lifecycle-error.ts +11 -0
  79. package/src/turn-server.ts +1028 -0
  80. package/src/webrtc-delivery-lifecycle.ts +54 -0
  81. package/src/webrtc-transport-options.ts +87 -0
  82. package/src/webrtc-transport.ts +368 -0
  83. package/src/ws-mesh-relay-client.ts +117 -0
  84. package/src/ws-signaling-client.ts +139 -0
@@ -0,0 +1,31 @@
1
+ import type { IPairingChannel } from './pairing-gate-options.js';
2
+
3
+ function send(channel: IPairingChannel, data: string): void {
4
+ try {
5
+ channel.send(data);
6
+ } catch {
7
+ // The peer is gone; pairing frames are best-effort once the carrier is closing.
8
+ }
9
+ }
10
+
11
+ function close(channel: IPairingChannel): void {
12
+ try {
13
+ channel.close();
14
+ } catch {
15
+ // The carrier is already closing or closed.
16
+ }
17
+ }
18
+
19
+ function reportDeliveryError(
20
+ callback: ((error: Error, event: string) => void) | undefined,
21
+ error: Error,
22
+ event: string,
23
+ ): void {
24
+ try {
25
+ callback?.(error, event);
26
+ } catch {
27
+ // A diagnostic callback cannot prevent carrier teardown or escape into the committed operation.
28
+ }
29
+ }
30
+
31
+ export const pairingChannel = Object.freeze({ send, close, reportDeliveryError });
@@ -0,0 +1,81 @@
1
+ import type { IIdentityContext } from '@robota-sdk/agent-remote-pairing';
2
+ /**
3
+ * Constructing the admission controllers the gate drives.
4
+ *
5
+ * "Which controller runs, with what inputs" is a different subject from "what state is the gate in
6
+ * and what may reach the session" — the same boundary that already moved the frame vocabulary into
7
+ * `pairing-frames.ts` and the best-effort channel calls into `pairing-channel-lifecycle.ts`.
8
+ *
9
+ * Split out because `pairing-gate.ts` had reached the size limit, where the rule is to split rather
10
+ * than extend. These two functions were the largest thing in it that was not about gating, and they
11
+ * touch nothing of the gate's own state: they take inputs, wire a controller's `send` to the
12
+ * channel, and hand back the controller plus a settled/rejected outcome for the caller to act on.
13
+ */
14
+
15
+ import { startHostReconnect, startPairingHandshake } from '@robota-sdk/agent-remote-pairing';
16
+
17
+ import { pairingChannel } from './pairing-channel-lifecycle.js';
18
+
19
+ import type { IHostReconnectConfig, IPairingChannel } from './pairing-gate-options.js';
20
+ import type {
21
+ IPairingResult,
22
+ IReconnectResult,
23
+ TPairingRole,
24
+ } from '@robota-sdk/agent-remote-pairing';
25
+
26
+ /** What both controllers need to talk on the channel and bind to it. */
27
+ export interface IControllerContext {
28
+ readonly cryptoContext: IIdentityContext;
29
+ readonly channel: IPairingChannel;
30
+ readonly localFingerprint: string;
31
+ readonly remoteFingerprint: string;
32
+ /** Handshake timeout (ms); fail closed on expiry. */
33
+ readonly timeoutMs?: number;
34
+ }
35
+
36
+ /**
37
+ * Start the B3 first-pair handshake.
38
+ *
39
+ * `onAccepted`/`onRejected` rather than handing the promise back, so a caller cannot forget to
40
+ * attach a rejection path — an unhandled rejection here would leave a gate waiting forever on a
41
+ * handshake that already failed, which is a fail-OPEN shaped as a hang.
42
+ */
43
+ export function startFirstPairController(
44
+ context: IControllerContext,
45
+ secret: string,
46
+ role: TPairingRole,
47
+ onAccepted: (result: IPairingResult) => void,
48
+ onRejected: () => void,
49
+ start: typeof startPairingHandshake = startPairingHandshake,
50
+ ): ReturnType<typeof startPairingHandshake> {
51
+ const controller = start(context.cryptoContext, {
52
+ secret,
53
+ role,
54
+ localFingerprint: context.localFingerprint,
55
+ remoteFingerprint: context.remoteFingerprint,
56
+ send: (frame) => pairingChannel.send(context.channel, JSON.stringify(frame)),
57
+ ...(context.timeoutMs !== undefined ? { timeoutMs: context.timeoutMs } : {}),
58
+ });
59
+ controller.result.then(onAccepted, onRejected);
60
+ return controller;
61
+ }
62
+
63
+ /** Start the E3 host-side reconnect exchange against a pinned device key. */
64
+ export function startReconnectController(
65
+ context: IControllerContext,
66
+ config: IHostReconnectConfig,
67
+ onAccepted: (result: IReconnectResult) => void,
68
+ onRejected: () => void,
69
+ ): ReturnType<typeof startHostReconnect> {
70
+ const controller = startHostReconnect(context.cryptoContext, {
71
+ hostIdentityId: config.hostIdentityId,
72
+ localFingerprint: context.localFingerprint,
73
+ remoteFingerprint: context.remoteFingerprint,
74
+ hostPrivateKey: config.hostPrivateKey,
75
+ resolveDevicePublicKey: config.resolveDevicePublicKey,
76
+ send: (frame) => pairingChannel.send(context.channel, JSON.stringify(frame)),
77
+ ...(context.timeoutMs !== undefined ? { timeoutMs: context.timeoutMs } : {}),
78
+ });
79
+ controller.result.then(onAccepted, onRejected);
80
+ return controller;
81
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Predicates over the pairing/reconnect frame vocabulary the gate admits pre-accept.
3
+ *
4
+ * Issue #2046: these are thin adapters over the OWNER codec in `@robota-sdk/agent-remote-pairing`,
5
+ * which decodes every required field (presence, base64url, length ceiling) rather than only the `t`
6
+ * discriminator this file used to check. A true answer means the narrowed value is well-formed; the
7
+ * browser `ResponderGate` imports the same codec, so one corpus governs both carriers.
8
+ */
9
+
10
+ import {
11
+ decodeEnrollFrame,
12
+ decodePairingFrame,
13
+ decodeReconnectFrame,
14
+ type IEnrollFrame,
15
+ type TPairingFrame,
16
+ type TReconnectFrame,
17
+ } from '@robota-sdk/agent-remote-pairing';
18
+
19
+ export type { IEnrollFrame };
20
+
21
+ /** True when a parsed value is a well-formed B3 pairing frame (`pair-nonce` | `pair-confirm`). */
22
+ export function isPairingFrame(value: unknown): value is TPairingFrame {
23
+ return decodePairingFrame(value).ok;
24
+ }
25
+
26
+ /** True when a parsed value is a well-formed reconnect frame (`rc-hello` | `rc-host` | `rc-device`). */
27
+ export function isReconnectFrame(value: unknown): value is TReconnectFrame {
28
+ return decodeReconnectFrame(value).ok;
29
+ }
30
+
31
+ /** True when a parsed value is a well-formed first-pair enrollment frame carrying a device SPKI. */
32
+ export function isEnrollFrame(value: unknown): value is IEnrollFrame {
33
+ return decodeEnrollFrame(value).ok;
34
+ }
@@ -0,0 +1,133 @@
1
+ import type { IIdentityContext } from '@robota-sdk/agent-remote-pairing';
2
+ /**
3
+ * The contract a `PairingGate` is constructed with (REMOTE-008 / -012 / -013, SEC-010, SEC-011).
4
+ *
5
+ * Separated from the gate itself because they are different responsibilities: this file says what a
6
+ * caller must supply, and `pairing-gate.ts` says what the machine does with it. Keeping them
7
+ * together made one file that had to be read in full to answer either question.
8
+ *
9
+ * Re-exported from `pairing-gate.ts`, so every existing import keeps working — the split is a
10
+ * reading change, not a migration.
11
+ */
12
+
13
+ import type {
14
+ IPairingResult,
15
+ startPairingHandshake,
16
+ TPairingRole,
17
+ } from '@robota-sdk/agent-remote-pairing';
18
+ import type {
19
+ IProtocolSession,
20
+ ISessionMessageHandlerOptions,
21
+ SessionResumeBridge,
22
+ createSessionMessageHandler,
23
+ } from '@robota-sdk/agent-transport';
24
+
25
+ import type { IHandoffGrantProof } from './handoff-grant-gate.js';
26
+ import type { ILocalPeerProof } from './local-peer-proof.js';
27
+
28
+ /** The minimal data-channel surface the gate drives (the transport's data channel satisfies it). */
29
+ export interface IPairingChannel {
30
+ send(data: string): void;
31
+ close(): void;
32
+ /**
33
+ * ARCH-030 / issue #1734: this channel's own count of what it has accepted and not yet written.
34
+ *
35
+ * Optional for the same reason `IDataChannelSink.bufferedAmount` is — the slice is structural and a
36
+ * test double may omit it — while a real `RTCDataChannel` always has it. It is here so the resume
37
+ * bridge's outbound boundary gets a reading: without one the replay path has no backpressure
38
+ * budget, which is a capability that exists and is never reached.
39
+ */
40
+ readonly bufferedAmount?: number;
41
+ }
42
+
43
+ /** E3 host reconnect/enrollment config. When present, the gate runs reactive (first-frame) mode detection. */
44
+ export interface IHostReconnectConfig {
45
+ readonly hostIdentityId: string;
46
+ /** base64url SPKI advertised to a device at first-pair enrollment. */
47
+ readonly hostPublicSpki: string;
48
+ /** The host identity private key (signs reconnect challenges). */
49
+ readonly hostPrivateKey: CryptoKey;
50
+ /** Resolve a pinned device public key by id (undefined → unknown/revoked → fail closed). */
51
+ readonly resolveDevicePublicKey: (deviceId: string) => Promise<CryptoKey | undefined>;
52
+ /** Pin a device's public key on first-pair enrollment; throw to refuse before session attachment. */
53
+ readonly onEnroll: (deviceId: string, deviceSpki: string) => void;
54
+ }
55
+
56
+ /** What the receiving operator is told about a connection that would drive the session. */
57
+ export interface IConnectionApprovalContext {
58
+ /** The device the handshake proved, when the carrier has a stable id for it. */
59
+ readonly deviceId?: string;
60
+ /** A trusted device coming back, rather than one pairing now. */
61
+ readonly viaReconnect: boolean;
62
+ /** Aborted when the connection goes away before an answer: withdraw the question. */
63
+ readonly signal: AbortSignal;
64
+ }
65
+
66
+ /**
67
+ * The receiving operator's say over one connection. `approve` resolves `true` only on the operator's
68
+ * explicit yes; `false` or a rejection refuses the connection.
69
+ */
70
+ export interface IConnectionApproval {
71
+ approve(context: IConnectionApprovalContext): Promise<boolean>;
72
+ }
73
+
74
+ export interface IPairingGateOptions {
75
+ readonly cryptoContext: IIdentityContext;
76
+ readonly channel: IPairingChannel;
77
+ readonly session: IProtocolSession;
78
+ readonly secret: string;
79
+ readonly role: TPairingRole;
80
+ readonly localFingerprint: string;
81
+ readonly remoteFingerprint: string;
82
+ /** Handshake timeout (ms); fail closed on expiry. */
83
+ readonly timeoutMs?: number;
84
+ /** REMOTE-008: fired once admission accepts + the session is exposed. Carries the first-pair result (E4). */
85
+ readonly onAccept?: (result?: IPairingResult) => void;
86
+ /** REMOTE-008: fired once admission rejects/times out + the channel closes (host lifecycle → teardown). */
87
+ readonly onReject?: () => void;
88
+ /** REMOTE-012 E3: host reconnect/enrollment config. Absent → B4 first-pair-only behavior (unchanged). */
89
+ readonly reconnect?: IHostReconnectConfig;
90
+ /**
91
+ * REMOTE-013 E4: a session-scoped {@link SessionResumeBridge}. When set, the paired session flows through the
92
+ * bridge (seq-stamped + buffered) instead of a fresh `createSessionMessageHandler`, so the session survives a channel
93
+ * drop and can replay on reconnect. Accept ATTACHES the channel as the bridge's sink; cleanup DETACHES it
94
+ * (never disposes — the bridge is owned by the transport across reconnects).
95
+ */
96
+ readonly resumeBridge?: SessionResumeBridge;
97
+ /** Host-owned usage read models exposed only after this gate accepts. */
98
+ readonly personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
99
+ readonly usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
100
+ readonly storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
101
+ /** Trusted carrier-owned surface; WebRTC assigns `remote`. */
102
+ readonly surface?: ISessionMessageHandlerOptions['surface'];
103
+ /** #3282 §4 part b-2: WebRTC assigns `remote` — it proves no locality. */
104
+ readonly commandSurfaceLocality?: ISessionMessageHandlerOptions['commandSurfaceLocality'];
105
+ /**
106
+ * Post-accept session-frame delivery failure; owning transport performs drop cleanup.
107
+ *
108
+ * ARCH-030: was `TServerMessage['type'] | string`, which is just `string` — a union that reads as a
109
+ * narrowing and is not one. Stated as what it is.
110
+ */
111
+ readonly onDeliveryError?: (error: Error, event: string) => void;
112
+ /** SEC-010: require a rendezvous nonce before exposing the session. Absent → unchanged behavior. */
113
+ readonly localPeer?: ILocalPeerProof;
114
+ /**
115
+ * SEC-011 (issue #1865): require a verified cross-device hand-off grant before exposing the
116
+ * session. Absent → unchanged behavior.
117
+ *
118
+ * Independent of `localPeer` rather than exclusive with it, and demanded AFTER it when both are
119
+ * set. They answer different questions — the rendezvous binds the environment, the grant binds the
120
+ * transfer — and a hand-off between two sessions on ONE machine legitimately has both. Requiring
121
+ * both is strictly more restrictive than requiring either, which is the safe direction.
122
+ */
123
+ readonly handoffGrant?: IHandoffGrantProof;
124
+ /**
125
+ * Ask the receiving operator before exposing the session, after every proof above has run. Absent →
126
+ * unchanged behavior. A device first pairing is not pinned for reconnect until the operator allows
127
+ * it, so a refused device is not remembered.
128
+ */
129
+ readonly connectionApproval?: IConnectionApproval;
130
+ /** Injection seams (default to the real implementations). */
131
+ readonly startHandshake?: typeof startPairingHandshake;
132
+ readonly createHandler?: typeof createSessionMessageHandler;
133
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Pairing gate for the WebRTC data channel (REMOTE-008 Stage B4-2b; extended for REMOTE-012 Stage E3 TOFU
3
+ * reconnect).
4
+ *
5
+ * The data channel is phase-separated: pre-accept it carries pairing/reconnect frames, post-accept only
6
+ * session messages. An eager subscription feeds {@link PairingGate.onInbound}; the session bridge is not
7
+ * built until acceptance, so no pre-accept peer frame can reach the live session.
8
+ *
9
+ * E3 selects first-pair (`pair-nonce`, then identity-key enrollment) or reconnect (`rc-hello`, pinned
10
+ * identities) from the client's first frame and exposes the session only after mutual acceptance.
11
+ *
12
+ * Fail-closed: a non-admission frame pre-accept never reaches the session early — while this side's verdict is
13
+ * pending it is HELD (bounded; a peer that says more is refused) and routed again once the gate moves on, since
14
+ * the peer may finish its half first and speak at once; it is discarded if admission fails. The handshake
15
+ * `result` is the ONLY accept signal;
16
+ * on reject/timeout the channel is closed and no session bridge is ever created; a post-close frame is ignored.
17
+ *
18
+ * When no E3 `reconnect` config is supplied the gate is **exactly** the B4 first-pair-only gate (eager host
19
+ * `pair-nonce`, no enrollment, no reconnect) — preserving existing behavior.
20
+ *
21
+ * SEC-010 adds an optional `local-proof` step between handshake acceptance and session exposure; see
22
+ * `local-peer-proof.ts` for why both of those edges are load-bearing.
23
+ */
24
+
25
+ import {
26
+ deriveIdentityId,
27
+ importPublicKey,
28
+ startPairingHandshake,
29
+ type IPairingResult,
30
+ } from '@robota-sdk/agent-remote-pairing';
31
+
32
+ import { nextAdmissionStep } from './admission-steps.js';
33
+ import type {
34
+ IConnectionApproval,
35
+ IConnectionApprovalContext,
36
+ IHostReconnectConfig,
37
+ IPairingChannel,
38
+ IPairingGateOptions,
39
+ } from './pairing-gate-options.js';
40
+
41
+ // Re-exported so every existing import of these names keeps working: the split moved where they are
42
+ // DECLARED, and moving where they are imported from would be a migration this change is not.
43
+ export type {
44
+ IConnectionApproval,
45
+ IConnectionApprovalContext,
46
+ IHostReconnectConfig,
47
+ IPairingChannel,
48
+ IPairingGateOptions,
49
+ };
50
+ import { judgeHandoffGrant } from './handoff-grant-gate.js';
51
+ import { judgeLocalProof } from './local-peer-proof.js';
52
+ import { pairingChannel } from './pairing-channel-lifecycle.js';
53
+ import { startFirstPairController, startReconnectController } from './pairing-controllers.js';
54
+ import { isEnrollFrame, isPairingFrame, isReconnectFrame } from './pairing-frames.js';
55
+ import { attachSession } from './session-attachment.js';
56
+
57
+ import type { IEnrollFrame } from './pairing-frames.js';
58
+
59
+ /** How much a peer may say while the operator decides: a client's opening requests, not a stream. */
60
+ const HELD_FRAMES_MAX = 32;
61
+ const HELD_CHARS_MAX = 256 * 1024;
62
+
63
+ type TGateState =
64
+ | 'awaiting-mode'
65
+ | 'pairing'
66
+ | 'enrolling'
67
+ | 'reconnecting'
68
+ | 'local-proof'
69
+ | 'handoff-grant'
70
+ | 'operator-approval'
71
+ | 'accepted'
72
+ | 'closed';
73
+
74
+ export class PairingGate {
75
+ private state: TGateState;
76
+ /** Session message router — built ONLY on accept (nothing reaches the session before). */
77
+ private onSessionMessage?: (data: string) => void;
78
+ private handlerCleanup?: () => void;
79
+ private pairingController?: ReturnType<typeof startPairingHandshake>;
80
+ private reconnectController?: ReturnType<typeof startReconnectController>;
81
+ /** The first-pair result, held so it can be surfaced on accept (E4 uses its sessionKey). */
82
+ private pendingResult?: IPairingResult;
83
+ /** Held across the local-proof step so the reconnect ordering rule survives the detour. */
84
+ private pendingViaReconnect = false;
85
+ /** The device the handshake proved, for the operator's question. */
86
+ private pendingDeviceId?: string;
87
+ /** A first-pair device key, pinned only once every step has admitted the channel. */
88
+ private pendingEnrollment?: { readonly deviceId: string; readonly spki: string };
89
+ /**
90
+ * Session frames the peer sent while the operator was deciding. The peer's own handshake has
91
+ * already accepted, so it starts talking (a resume, a history request) at once; dropping those
92
+ * would leave an approved device waiting on answers to questions the host never saw. They reach
93
+ * the session only after a yes, and a peer that sends more than a bounded amount is refused.
94
+ */
95
+ private heldFrames: string[] = [];
96
+ private heldChars = 0;
97
+ /** Withdraws the question put to the operator when the connection goes away first. */
98
+ private approvalAbort?: AbortController;
99
+
100
+ constructor(private readonly options: IPairingGateOptions) {
101
+ if (options.reconnect) {
102
+ // E3 mode: stay reactive — the client's first frame selects first-pair vs reconnect.
103
+ this.state = 'awaiting-mode';
104
+ } else {
105
+ // Legacy B4 mode: eagerly start the first-pair handshake (host sends pair-nonce immediately).
106
+ this.state = 'pairing';
107
+ this.startFirstPair();
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Route one inbound channel frame. Pre-accept: admission frames → the active controller, everything else
113
+ * HELD (bounded) until the gate moves on. Post-accept: session messages → the session bridge. Post-close:
114
+ * ignored.
115
+ */
116
+ onInbound(data: string): void {
117
+ if (this.state === 'closed') return;
118
+ if (this.state === 'accepted') {
119
+ this.onSessionMessage?.(data);
120
+ return;
121
+ }
122
+ if (this.state === 'operator-approval') {
123
+ this.hold(data);
124
+ return;
125
+ }
126
+
127
+ let parsed: unknown;
128
+ try {
129
+ parsed = JSON.parse(data);
130
+ } catch {
131
+ return; // undecodable pre-accept frame → drop
132
+ }
133
+
134
+ if (this.state === 'awaiting-mode') {
135
+ // First frame selects the mode (E3).
136
+ if (isReconnectFrame(parsed) && parsed.t === 'rc-hello') {
137
+ this.state = 'reconnecting';
138
+ this.startReconnect();
139
+ this.reconnectController?.onFrame(parsed);
140
+ } else if (isPairingFrame(parsed) && parsed.t === 'pair-nonce') {
141
+ this.state = 'pairing';
142
+ this.startFirstPair();
143
+ this.pairingController?.onFrame(parsed);
144
+ }
145
+ // anything else pre-mode → drop
146
+ return;
147
+ }
148
+
149
+ if (this.state === 'pairing') {
150
+ if (isPairingFrame(parsed)) this.pairingController?.onFrame(parsed);
151
+ else this.hold(data);
152
+ return;
153
+ }
154
+
155
+ if (this.state === 'reconnecting') {
156
+ if (isReconnectFrame(parsed)) this.reconnectController?.onFrame(parsed);
157
+ else this.hold(data);
158
+ return;
159
+ }
160
+
161
+ if (this.state === 'local-proof') {
162
+ const admission = judgeLocalProof(parsed, this.options.localPeer);
163
+ this.options.localPeer?.onAdmission?.(admission);
164
+ if (admission.admitted) this.accept(this.pendingResult, this.pendingViaReconnect);
165
+ else this.rejectAndClose();
166
+ return;
167
+ }
168
+
169
+ if (this.state === 'handoff-grant') {
170
+ // Verifying a signature is async, and this handler is not. The promise is consumed here rather
171
+ // than returned so a rejection cannot escape into the channel's message subscription — an
172
+ // unhandled rejection would leave the gate parked in this state with the channel open, which
173
+ // is a hang. `judgeHandoffGrant` already converts a throw into a refusal; this is the second
174
+ // layer, because a gate that can hang is a gate that fails open by waiting.
175
+ void this.judgeGrantFrame(parsed);
176
+ return;
177
+ }
178
+
179
+ if (this.state === 'enrolling') {
180
+ // Awaiting the peer's identity public key to pin, then expose the session.
181
+ if (isEnrollFrame(parsed)) this.completeEnrollment(parsed.spki);
182
+ else this.hold(data);
183
+ return;
184
+ }
185
+ }
186
+
187
+ /** Tear down: cleanup the session bridge (if built) and mark closed. Idempotent. */
188
+ cleanup(): void {
189
+ this.state = 'closed';
190
+ this.withdrawApproval();
191
+ this.handlerCleanup?.();
192
+ this.handlerCleanup = undefined;
193
+ this.onSessionMessage = undefined;
194
+ }
195
+
196
+ /**
197
+ * The channel closed. Before acceptance that is a refusal: nobody is left to admit, so a question
198
+ * still open with the operator is withdrawn and a later answer admits nothing. After acceptance the
199
+ * owning transport handles the drop.
200
+ */
201
+ onChannelClosed(): void {
202
+ if (this.state === 'accepted' || this.state === 'closed') return;
203
+ this.rejectAndClose();
204
+ }
205
+
206
+ private withdrawApproval(): void {
207
+ this.heldFrames = [];
208
+ this.heldChars = 0;
209
+ this.approvalAbort?.abort();
210
+ this.approvalAbort = undefined;
211
+ }
212
+
213
+ private startFirstPair(): void {
214
+ // `IPairingGateOptions` already carries every field `IControllerContext` asks for, so it is
215
+ // passed straight through — projecting it field by field would be a copy to keep in step.
216
+ this.pairingController = startFirstPairController(
217
+ this.options,
218
+ this.options.secret,
219
+ this.options.role,
220
+ (result) => this.onFirstPairAccepted(result),
221
+ () => this.rejectAndClose(),
222
+ this.options.startHandshake ?? startPairingHandshake,
223
+ );
224
+ }
225
+
226
+ private startReconnect(): void {
227
+ const cfg = this.options.reconnect;
228
+ if (!cfg) {
229
+ this.rejectAndClose();
230
+ return;
231
+ }
232
+ this.reconnectController = startReconnectController(
233
+ this.options,
234
+ cfg,
235
+ // reconnect → hold live forwarding until the client's resume replays
236
+ (result) => {
237
+ this.pendingDeviceId = result.deviceId;
238
+ this.accept(undefined, true);
239
+ },
240
+ () => this.rejectAndClose(),
241
+ );
242
+ }
243
+
244
+ /** B3 handshake accepted. Without E3: expose immediately. With E3: run first-pair enrollment first. */
245
+ private onFirstPairAccepted(result: IPairingResult): void {
246
+ if (this.state !== 'pairing') return;
247
+ this.pendingResult = result;
248
+ const cfg = this.options.reconnect;
249
+ if (!cfg) {
250
+ this.accept(result);
251
+ return;
252
+ }
253
+ // E3 enrollment: advertise the host public key; the peer's `enroll-key` completes it (then expose).
254
+ this.state = 'enrolling';
255
+ pairingChannel.send(
256
+ this.options.channel,
257
+ JSON.stringify({ t: 'enroll-key', spki: cfg.hostPublicSpki } satisfies IEnrollFrame),
258
+ );
259
+ this.replayHeld();
260
+ }
261
+
262
+ private completeEnrollment(deviceSpki: string): void {
263
+ if (this.state !== 'enrolling') return;
264
+ const cfg = this.options.reconnect;
265
+ if (!cfg) {
266
+ this.rejectAndClose();
267
+ return;
268
+ }
269
+ void (async (): Promise<void> => {
270
+ try {
271
+ // Validate the SPKI parses as a public key before pinning (fail closed on garbage).
272
+ await importPublicKey(deviceSpki);
273
+ const deviceId = await deriveIdentityId(deviceSpki);
274
+ if (this.state !== 'enrolling') return;
275
+ this.pendingDeviceId = deviceId;
276
+ this.pendingEnrollment = { deviceId, spki: deviceSpki };
277
+ this.accept(this.pendingResult);
278
+ } catch {
279
+ this.rejectAndClose();
280
+ }
281
+ })();
282
+ }
283
+
284
+ private accept(result?: IPairingResult, viaReconnect = false): void {
285
+ if (this.state === 'closed' || this.state === 'accepted') return;
286
+ // The steps this channel still owes. Demanded here rather than earlier because the handshake
287
+ // must already have bound the channel, and before anything below because that is where the
288
+ // session becomes reachable. The ordering argument lives in `admission-steps.ts`.
289
+ const owed = nextAdmissionStep(this.options, this.state);
290
+ if (owed !== null) {
291
+ this.pendingResult = result ?? this.pendingResult;
292
+ this.pendingViaReconnect = viaReconnect;
293
+ this.state = owed;
294
+ if (owed === 'operator-approval') void this.askOperator();
295
+ this.replayHeld();
296
+ return;
297
+ }
298
+ // Pin a first-pair device only now: a device a later step refused is not remembered.
299
+ const enrollment = this.pendingEnrollment;
300
+ this.pendingEnrollment = undefined;
301
+ if (enrollment !== undefined) {
302
+ try {
303
+ this.options.reconnect?.onEnroll(enrollment.deviceId, enrollment.spki);
304
+ } catch {
305
+ this.rejectAndClose();
306
+ return;
307
+ }
308
+ }
309
+ const attached = attachSession(this.options, viaReconnect, (error, event) =>
310
+ this.handleSessionDeliveryError(error, event),
311
+ );
312
+ this.onSessionMessage = attached.onSessionMessage;
313
+ this.handlerCleanup = attached.cleanup;
314
+ this.state = 'accepted';
315
+ this.options.onAccept?.(result);
316
+ const held = this.heldFrames;
317
+ this.heldFrames = [];
318
+ this.heldChars = 0;
319
+ for (const frame of held) {
320
+ if (this.state !== 'accepted') return;
321
+ this.onSessionMessage?.(frame);
322
+ }
323
+ }
324
+
325
+ /** The gate moved on: frames held under the previous state are routed again under the new one. */
326
+ private replayHeld(): void {
327
+ const held = this.heldFrames;
328
+ this.heldFrames = [];
329
+ this.heldChars = 0;
330
+ // Routed in order; once accepted, the rest go to the session through the same entry point.
331
+ for (const frame of held) {
332
+ if (this.state === 'closed') return;
333
+ this.onInbound(frame);
334
+ }
335
+ }
336
+
337
+ private hold(data: string): void {
338
+ this.heldChars += data.length;
339
+ this.heldFrames.push(data);
340
+ if (this.heldFrames.length > HELD_FRAMES_MAX || this.heldChars > HELD_CHARS_MAX) {
341
+ this.rejectAndClose();
342
+ }
343
+ }
344
+
345
+ /**
346
+ * The `operator-approval` state: ask, then admit only on an explicit yes. A failure to ask is a no,
347
+ * and an answer that arrives after the channel was torn down admits nothing.
348
+ */
349
+ private async askOperator(): Promise<void> {
350
+ const abort = new AbortController();
351
+ this.approvalAbort = abort;
352
+ const context: IConnectionApprovalContext = {
353
+ ...(this.pendingDeviceId !== undefined ? { deviceId: this.pendingDeviceId } : {}),
354
+ viaReconnect: this.pendingViaReconnect,
355
+ signal: abort.signal,
356
+ };
357
+ let allowed = false;
358
+ try {
359
+ allowed = (await this.options.connectionApproval?.approve(context)) === true;
360
+ } catch {
361
+ allowed = false;
362
+ }
363
+ if (this.state !== 'operator-approval' || abort.signal.aborted) return;
364
+ this.approvalAbort = undefined;
365
+ if (allowed) this.accept(this.pendingResult, this.pendingViaReconnect);
366
+ else this.rejectAndClose();
367
+ }
368
+
369
+ /** The async half of the `handoff-grant` state, kept off the synchronous message path. */
370
+ private async judgeGrantFrame(parsed: unknown): Promise<void> {
371
+ const admission = await judgeHandoffGrant(parsed, this.options.handoffGrant);
372
+ // A channel torn down while the signature was being checked must not be re-accepted by a verdict
373
+ // that arrives afterwards. Checked after the await, because that is the window it exists for.
374
+ if (this.state !== 'handoff-grant') return;
375
+ this.options.handoffGrant?.onAdmission?.(admission);
376
+ if (admission.admitted) this.accept(this.pendingResult, this.pendingViaReconnect);
377
+ else this.rejectAndClose();
378
+ }
379
+
380
+ private rejectAndClose(): void {
381
+ if (this.state === 'closed') return;
382
+ this.state = 'closed';
383
+ this.withdrawApproval();
384
+ pairingChannel.close(this.options.channel);
385
+ this.options.onReject?.();
386
+ }
387
+
388
+ private handleSessionDeliveryError(error: Error, event: string): void {
389
+ if (this.state === 'closed') return;
390
+ this.state = 'closed';
391
+ this.handlerCleanup?.();
392
+ this.handlerCleanup = undefined;
393
+ this.onSessionMessage = undefined;
394
+ pairingChannel.reportDeliveryError(this.options.onDeliveryError, error, event);
395
+ pairingChannel.close(this.options.channel);
396
+ }
397
+ }