@floegence/flowersec-core 0.24.0 → 0.26.0

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/README.md CHANGED
@@ -27,6 +27,12 @@ Start with the [TypeScript cookbook](https://github.com/floegence/flowersec/tree
27
27
 
28
28
  High-level WebSocket connections require TLS by default. Use `AllowPlaintextForLoopback` only for literal local development targets.
29
29
 
30
+ ## Node Proxy Server
31
+
32
+ The Node entrypoint exports `serveProxySession(...)`, `serveProxyStream(...)`, and `ProxyServerOptions`. HTTP request and response bodies stream between Flowersec chunk frames and the upstream fetch while remaining bounded per request by `maxBodyBytes`. Proxy sessions admit at most `maxConcurrentStreams` HTTP/WebSocket streams, with a shared default of 64; RPC serving has its own concurrency controls. WebSocket frames remain bounded by `maxWsFrameBytes`; `maxWsQueuedBytes` additionally caps upstream-to-Yamux queued bytes per connection, while the reverse direction waits for each Node `ws` send callback.
33
+
34
+ The defaults keep one queued WebSocket frame plus its proxy header. Raise body, concurrency, or queue limits only when the deployment has matching upstream and memory capacity.
35
+
30
36
  ## Runtime Boundaries
31
37
 
32
38
  TypeScript owns browser and Service Worker integration. Shared tunnel, proxy gateway, and helper binaries remain Go-owned.
@@ -1,6 +1,5 @@
1
1
  import { type ChannelInitGrant } from "../facade.js";
2
2
  import type { ControlplaneBaseConfig as SharedControlplaneBaseConfig, RequestConnectArtifactInput, RequestEntryConnectArtifactInput } from "../controlplane/request.js";
3
- export { ControlplaneRequestError, requestConnectArtifact, requestEntryConnectArtifact, } from "../controlplane/request.js";
4
3
  type BaseControlplaneRequestConfig = SharedControlplaneBaseConfig & Readonly<{
5
4
  endpointId: string;
6
5
  payload?: Record<string, unknown>;
@@ -13,3 +12,4 @@ export type ConnectArtifactRequestConfig = RequestConnectArtifactInput;
13
12
  export type EntryConnectArtifactRequestConfig = RequestEntryConnectArtifactInput;
14
13
  export declare function requestChannelGrant(config: ControlplaneConfig): Promise<ChannelInitGrant>;
15
14
  export declare function requestEntryChannelGrant(config: EntryControlplaneConfig): Promise<ChannelInitGrant>;
15
+ export {};
@@ -1,6 +1,5 @@
1
1
  import { assertChannelInitGrant } from "../facade.js";
2
2
  import { requestControlplaneJSON, } from "../controlplane/request.js";
3
- export { ControlplaneRequestError, requestConnectArtifact, requestEntryConnectArtifact, } from "../controlplane/request.js";
4
3
  function buildURL(baseUrl, path) {
5
4
  const base = String(baseUrl ?? "").trim();
6
5
  if (base === "")
@@ -1,10 +1,10 @@
1
1
  export type { ConnectBrowserOptions, DirectConnectBrowserOptions, TunnelConnectBrowserOptions } from "./connect.js";
2
2
  export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
3
- export { AllowPlaintext, AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
3
+ export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
4
4
  export type { TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, NetworkPlaintextPolicyOptions, } from "../client-connect/transportSecurity.js";
5
5
  export type { ConnectArtifact, CorrelationContext, CorrelationKV, DirectClientConnectArtifact, ScopeMetadataEntry, TunnelClientConnectArtifact, } from "../connect/artifact.js";
6
6
  export { assertConnectArtifact } from "../connect/artifact.js";
7
- export type { ConnectArtifactRequestConfig, ControlplaneConfig, EntryConnectArtifactRequestConfig, EntryControlplaneConfig, } from "./controlplane.js";
8
- export { ControlplaneRequestError, requestChannelGrant, requestConnectArtifact, requestEntryChannelGrant, requestEntryConnectArtifact, } from "./controlplane.js";
7
+ export type { ControlplaneConfig, EntryControlplaneConfig } from "./controlplane.js";
8
+ export { requestChannelGrant, requestEntryChannelGrant, } from "./controlplane.js";
9
9
  export type { BrowserReconnectConfig, DirectBrowserReconnectConfig, TunnelBrowserReconnectConfig, } from "./reconnectConfig.js";
10
10
  export { createBrowserReconnectConfig, createDirectBrowserReconnectConfig, createTunnelBrowserReconnectConfig, } from "./reconnectConfig.js";
@@ -1,5 +1,5 @@
1
1
  export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
2
- export { AllowPlaintext, AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
2
+ export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
3
3
  export { assertConnectArtifact } from "../connect/artifact.js";
4
- export { ControlplaneRequestError, requestChannelGrant, requestConnectArtifact, requestEntryChannelGrant, requestEntryConnectArtifact, } from "./controlplane.js";
4
+ export { requestChannelGrant, requestEntryChannelGrant, } from "./controlplane.js";
5
5
  export { createBrowserReconnectConfig, createDirectBrowserReconnectConfig, createTunnelBrowserReconnectConfig, } from "./reconnectConfig.js";
@@ -2,8 +2,6 @@ import type { ClientPath } from "../client.js";
2
2
  import { type ClientObserverLike } from "../observability/observer.js";
3
3
  export declare const RequireTLS: "require_tls";
4
4
  export declare const AllowPlaintextForLoopback: "allow_plaintext_for_loopback";
5
- /** @deprecated Use RequireTLS, AllowPlaintextForLoopback, or createNetworkPlaintextPolicy. */
6
- export declare const AllowPlaintext: "allow_plaintext";
7
5
  export declare const PlaintextRiskAcceptance: {
8
6
  readonly acceptPreE2ECredentialExposure: "accept_pre_e2ee_credential_exposure";
9
7
  };
@@ -18,7 +16,7 @@ export type TransportSecurityPolicyInput = Readonly<{
18
16
  host: string;
19
17
  runtime: "browser" | "node" | "other";
20
18
  }>;
21
- export type TransportSecurityPolicyPreset = typeof RequireTLS | typeof AllowPlaintextForLoopback | typeof AllowPlaintext;
19
+ export type TransportSecurityPolicyPreset = typeof RequireTLS | typeof AllowPlaintextForLoopback;
22
20
  export type TransportSecurityPolicy = TransportSecurityPolicyPreset | ((input: TransportSecurityPolicyInput) => boolean | Promise<boolean>);
23
21
  export declare function createNetworkPlaintextPolicy(options: NetworkPlaintextPolicyOptions): TransportSecurityPolicy;
24
22
  export declare function enforceTransportSecurity(args: Readonly<{
@@ -2,8 +2,6 @@ import { FlowersecError } from "../utils/errors.js";
2
2
  import { emitObserverDiagnostic } from "../observability/observer.js";
3
3
  export const RequireTLS = "require_tls";
4
4
  export const AllowPlaintextForLoopback = "allow_plaintext_for_loopback";
5
- /** @deprecated Use RequireTLS, AllowPlaintextForLoopback, or createNetworkPlaintextPolicy. */
6
- export const AllowPlaintext = "allow_plaintext";
7
5
  export const PlaintextRiskAcceptance = {
8
6
  acceptPreE2ECredentialExposure: "accept_pre_e2ee_credential_exposure",
9
7
  };
@@ -35,7 +33,7 @@ export async function enforceTransportSecurity(args) {
35
33
  const policy = args.policy ?? RequireTLS;
36
34
  try {
37
35
  if (typeof policy === "function") {
38
- allowed = await policy(input);
36
+ allowed = await policy(input) === true;
39
37
  }
40
38
  else {
41
39
  allowed = evaluatePreset(policy, target);
@@ -71,8 +69,6 @@ function evaluatePreset(policy, target) {
71
69
  return target.scheme === "wss";
72
70
  case AllowPlaintextForLoopback:
73
71
  return target.scheme === "wss" || isLiteralLoopbackHost(target.host);
74
- case AllowPlaintext:
75
- return true;
76
72
  }
77
73
  }
78
74
  function parseWebSocketTarget(rawUrl) {
@@ -7,6 +7,7 @@ export type ControlplaneBaseConfig = Readonly<{
7
7
  credentials?: RequestCredentials;
8
8
  fetch?: FetchLike;
9
9
  signal?: AbortSignal;
10
+ allowLoopbackHTTP?: boolean;
10
11
  }>;
11
12
  export type ArtifactRequestCorrelation = Readonly<{
12
13
  traceId?: string;
@@ -34,6 +34,76 @@ export function buildControlplaneURL(baseUrl, path) {
34
34
  return path;
35
35
  return `${base.replace(/\/+$/, "")}${path}`;
36
36
  }
37
+ function transportPolicyDenied() {
38
+ return new ControlplaneRequestError({
39
+ status: 0,
40
+ code: "transport_policy_denied",
41
+ message: "controlplane transport security policy denied URL",
42
+ });
43
+ }
44
+ function validateArtifactURL(rawUrl, allowLoopbackHTTP) {
45
+ let target;
46
+ try {
47
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(rawUrl)) {
48
+ target = new URL(rawUrl);
49
+ }
50
+ else {
51
+ const base = globalThis.location?.href;
52
+ if (!base)
53
+ throw new Error("relative URL requires a browser location");
54
+ target = new URL(rawUrl, base);
55
+ }
56
+ }
57
+ catch {
58
+ throw transportPolicyDenied();
59
+ }
60
+ if (target.username !== "" || target.password !== "" || target.hash !== "") {
61
+ throw transportPolicyDenied();
62
+ }
63
+ if (target.protocol === "https:")
64
+ return;
65
+ if (target.protocol === "http:"
66
+ && allowLoopbackHTTP === true
67
+ && isLiteralLoopbackHost(sourceHostname(rawUrl, target)))
68
+ return;
69
+ throw transportPolicyDenied();
70
+ }
71
+ function sourceHostname(rawUrl, target) {
72
+ const authority = /^(?:[A-Za-z][A-Za-z0-9+.-]*:)?\/\/([^/?#]*)/.exec(rawUrl)?.[1];
73
+ if (authority === undefined)
74
+ return target.hostname;
75
+ const hostPort = authority.slice(authority.lastIndexOf("@") + 1);
76
+ if (hostPort.startsWith("[")) {
77
+ const end = hostPort.indexOf("]");
78
+ if (end < 0 || (hostPort.length > end + 1 && !/^:\d+$/.test(hostPort.slice(end + 1)))) {
79
+ return hostPort;
80
+ }
81
+ return hostPort.slice(1, end);
82
+ }
83
+ const colon = hostPort.lastIndexOf(":");
84
+ if (colon < 0)
85
+ return hostPort;
86
+ if (hostPort.indexOf(":") !== colon || !/^\d+$/.test(hostPort.slice(colon + 1)))
87
+ return hostPort;
88
+ return hostPort.slice(0, colon);
89
+ }
90
+ function isLiteralLoopbackHost(rawHost) {
91
+ const host = rawHost.startsWith("[") && rawHost.endsWith("]")
92
+ ? rawHost.slice(1, -1).toLowerCase()
93
+ : rawHost.toLowerCase();
94
+ if (host === "localhost" || host === "::1")
95
+ return true;
96
+ const parts = host.split(".");
97
+ if (parts.length !== 4)
98
+ return false;
99
+ const octets = parts.map((part) => {
100
+ if (!/^(0|[1-9]\d{0,2})$/.test(part))
101
+ return -1;
102
+ const value = Number(part);
103
+ return value <= 255 ? value : -1;
104
+ });
105
+ return octets.every((value) => value >= 0) && octets[0] === 127;
106
+ }
37
107
  function parseMaybeJSON(bodyText) {
38
108
  const trimmed = String(bodyText ?? "").trim();
39
109
  if (trimmed === "")
@@ -110,6 +180,7 @@ export async function requestControlplaneJSON(url, init) {
110
180
  const response = await runFetch(url, {
111
181
  ...init,
112
182
  cache: "no-store",
183
+ redirect: "error",
113
184
  });
114
185
  let rawBody = "";
115
186
  try {
@@ -164,7 +235,9 @@ function createJSONHeaders(input) {
164
235
  return headers;
165
236
  }
166
237
  export async function requestConnectArtifact(config) {
167
- const data = (await requestControlplaneJSON(buildControlplaneURL(config.baseUrl, config.path ?? DEFAULT_CONNECT_ARTIFACT_PATH), {
238
+ const url = buildControlplaneURL(config.baseUrl, config.path ?? DEFAULT_CONNECT_ARTIFACT_PATH);
239
+ validateArtifactURL(url, config.allowLoopbackHTTP);
240
+ const data = (await requestControlplaneJSON(url, {
168
241
  ...(config.fetch === undefined ? {} : { fetch: config.fetch }),
169
242
  method: "POST",
170
243
  credentials: config.credentials ?? "omit",
@@ -183,7 +256,9 @@ export async function requestEntryConnectArtifact(config) {
183
256
  throw new Error("entryTicket is required");
184
257
  const headers = createJSONHeaders(config.headers);
185
258
  headers.set("Authorization", `Bearer ${entryTicket}`);
186
- const data = (await requestControlplaneJSON(buildControlplaneURL(config.baseUrl, config.path ?? DEFAULT_ENTRY_CONNECT_ARTIFACT_PATH), {
259
+ const url = buildControlplaneURL(config.baseUrl, config.path ?? DEFAULT_ENTRY_CONNECT_ARTIFACT_PATH);
260
+ validateArtifactURL(url, config.allowLoopbackHTTP);
261
+ const data = (await requestControlplaneJSON(url, {
187
262
  ...(config.fetch === undefined ? {} : { fetch: config.fetch }),
188
263
  method: "POST",
189
264
  credentials: config.credentials ?? "omit",
@@ -35,6 +35,7 @@ export declare const SDK_DEFAULTS: Readonly<{
35
35
  maxChunkBytes: number;
36
36
  maxBodyBytes: number;
37
37
  maxWsFrameBytes: number;
38
+ maxConcurrentStreams: 64;
38
39
  defaultTimeoutMs: 30000;
39
40
  maxTimeoutMs: 300000;
40
41
  }>;
package/dist/defaults.js CHANGED
@@ -35,6 +35,7 @@ export const SDK_DEFAULTS = Object.freeze({
35
35
  maxChunkBytes: 256 * 1024,
36
36
  maxBodyBytes: 64 * 1024 * 1024,
37
37
  maxWsFrameBytes: 1024 * 1024,
38
+ maxConcurrentStreams: 64,
38
39
  defaultTimeoutMs: 30_000,
39
40
  maxTimeoutMs: 300_000,
40
41
  }),
@@ -1,5 +1,6 @@
1
1
  import { Role as ControlRole, assertChannelInitGrant } from "../gen/flowersec/controlplane/v1.gen.js";
2
2
  import { Role as TunnelRole } from "../gen/flowersec/tunnel/v1.gen.js";
3
+ import { withAbortAndTimeout } from "../client-connect/common.js";
3
4
  import { assertTunnelGrantContract, assertValidPSK, prepareChannelId } from "../client-connect/contract.js";
4
5
  import { enforceTransportSecurity } from "../client-connect/transportSecurity.js";
5
6
  import { SDK_DEFAULTS } from "../defaults.js";
@@ -12,7 +13,7 @@ import { isYamuxPingTimeoutError } from "../yamux/errors.js";
12
13
  import { YamuxSession } from "../yamux/session.js";
13
14
  import { RpcServer } from "../rpc/server.js";
14
15
  import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
15
- import { AbortError, FlowersecError } from "../utils/errors.js";
16
+ import { AbortError, FlowersecError, TimeoutError, throwIfAborted } from "../utils/errors.js";
16
17
  import { WebSocketBinaryTransport } from "../ws-client/binaryTransport.js";
17
18
  export class Session {
18
19
  secure;
@@ -152,15 +153,46 @@ export class Session {
152
153
  }
153
154
  }
154
155
  export async function acceptDirect(websocket, handshake, options = {}) {
156
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
157
+ if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
158
+ throw new FlowersecError({
159
+ path: "direct",
160
+ stage: "validate",
161
+ code: "invalid_option",
162
+ message: "handshakeTimeoutMs must be a non-negative number",
163
+ });
164
+ }
155
165
  await enforceIncomingDirectTransport(options);
166
+ const deadline = createHandshakeDeadline(handshakeTimeoutMs);
156
167
  const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
157
- return await establishSession("direct", transport, handshake, options);
168
+ return await establishSession("direct", transport, handshake, options, undefined, deadline);
158
169
  }
159
170
  export async function acceptDirectResolved(websocket, resolver, options = {}) {
171
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
172
+ if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
173
+ throw new FlowersecError({
174
+ path: "direct",
175
+ stage: "validate",
176
+ code: "invalid_option",
177
+ message: "handshakeTimeoutMs must be a non-negative number",
178
+ });
179
+ }
160
180
  await enforceIncomingDirectTransport(options);
181
+ const deadline = createHandshakeDeadline(handshakeTimeoutMs);
161
182
  const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
162
- const first = await transport.readBinary(readOptions(options));
183
+ let first;
184
+ try {
185
+ first = await runHandshakeStep(deadline, options.signal, () => transport.readBinary({
186
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
187
+ timeoutMs: remainingHandshakeTimeoutMs(deadline),
188
+ }));
189
+ }
190
+ catch (error) {
191
+ transport.close();
192
+ throw endpointHandshakeError("direct", error, "failed to read handshake init");
193
+ }
163
194
  let init;
195
+ let channelId;
164
196
  try {
165
197
  const decoded = decodeHandshakeFrame(first, options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes);
166
198
  if (decoded.handshakeType !== HANDSHAKE_TYPE_INIT)
@@ -168,30 +200,44 @@ export async function acceptDirectResolved(websocket, resolver, options = {}) {
168
200
  init = JSON.parse(new TextDecoder().decode(decoded.payloadJsonUtf8));
169
201
  if (init.version !== PROTOCOL_VERSION || init.role !== 1 || (init.suite !== 1 && init.suite !== 2))
170
202
  throw new Error("invalid handshake init");
203
+ channelId = prepareChannelId(init.channel_id, "direct");
204
+ if (channelId !== init.channel_id) {
205
+ throw new FlowersecError({
206
+ path: "direct",
207
+ stage: "validate",
208
+ code: "invalid_input",
209
+ message: "channel_id must not have leading or trailing whitespace",
210
+ });
211
+ }
171
212
  }
172
213
  catch (error) {
173
214
  transport.close();
215
+ if (error instanceof FlowersecError)
216
+ throw error;
174
217
  throw new FlowersecError({ path: "direct", stage: "handshake", code: "handshake_failed", message: "invalid handshake init", cause: error });
175
218
  }
176
219
  let credential;
177
220
  try {
178
- credential = await resolver({
179
- channelId: prepareChannelId(init.channel_id, "direct"),
221
+ credential = await runHandshakeStep(deadline, options.signal, () => resolver({
222
+ channelId,
180
223
  version: init.version,
181
224
  suite: init.suite,
182
225
  clientFeatures: init.client_features >>> 0,
183
- });
226
+ }));
184
227
  }
185
228
  catch (error) {
186
229
  transport.close();
230
+ const interrupted = endpointHandshakeInterruption("direct", error);
231
+ if (interrupted != null)
232
+ throw interrupted;
187
233
  throw new FlowersecError({ path: "direct", stage: "validate", code: "resolve_failed", message: "credential resolution failed", cause: error });
188
234
  }
189
235
  const replay = new PrefetchedTransport(transport, first);
190
236
  return await establishSession("direct", replay, {
191
- channelId: init.channel_id,
237
+ channelId,
192
238
  suite: init.suite,
193
239
  ...credential,
194
- }, options);
240
+ }, options, undefined, deadline);
195
241
  }
196
242
  export async function connectTunnel(grantInput, options) {
197
243
  let grant;
@@ -202,6 +248,7 @@ export async function connectTunnel(grantInput, options) {
202
248
  throw new FlowersecError({ path: "tunnel", stage: "validate", code: "invalid_input", message: "invalid ChannelInitGrant", cause: error });
203
249
  }
204
250
  assertTunnelGrantContract(grant, ControlRole.Role_server);
251
+ const channelId = prepareChannelId(grant.channel_id, "tunnel");
205
252
  const tunnelUrl = grant.tunnel_url.trim();
206
253
  if (tunnelUrl === "")
207
254
  throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_tunnel_url", message: "missing tunnel_url" });
@@ -212,35 +259,63 @@ export async function connectTunnel(grantInput, options) {
212
259
  const origin = options.origin.trim();
213
260
  if (origin === "")
214
261
  throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_origin", message: "missing origin" });
262
+ const connectTimeoutMs = options.connectTimeoutMs ?? SDK_DEFAULTS.transport.connectTimeoutMs;
263
+ if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs < 0) {
264
+ throw new FlowersecError({
265
+ path: "tunnel",
266
+ stage: "validate",
267
+ code: "invalid_option",
268
+ message: "connectTimeoutMs must be a non-negative number",
269
+ });
270
+ }
271
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
272
+ if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
273
+ throw new FlowersecError({
274
+ path: "tunnel",
275
+ stage: "validate",
276
+ code: "invalid_option",
277
+ message: "handshakeTimeoutMs must be a non-negative number",
278
+ });
279
+ }
280
+ const psk = assertValidPSK(grant.e2ee_psk_b64u, "tunnel");
215
281
  await enforceTransportSecurity({ rawUrl: tunnelUrl, path: "tunnel", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
216
282
  const endpointInstanceId = normalizeEndpointInstanceId(options.endpointInstanceId);
217
- const websocket = options.wsFactory(tunnelUrl, origin);
218
- const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
283
+ let transport;
219
284
  try {
220
- await waitForOpen(websocket, options.connectTimeoutMs ?? SDK_DEFAULTS.transport.connectTimeoutMs, options.signal);
285
+ throwIfAborted(options.signal, "connect aborted");
286
+ const websocket = options.wsFactory(tunnelUrl, origin);
287
+ transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
288
+ await waitForOpen(websocket, connectTimeoutMs, options.signal);
289
+ throwIfAborted(options.signal, "connect aborted");
221
290
  const attach = {
222
291
  v: 1,
223
- channel_id: prepareChannelId(grant.channel_id, "tunnel"),
292
+ channel_id: channelId,
224
293
  role: TunnelRole.Role_server,
225
294
  token: grant.token.trim(),
226
295
  endpoint_instance_id: endpointInstanceId,
227
296
  };
228
297
  websocket.send(JSON.stringify(attach));
229
298
  return await establishSession("tunnel", transport, {
230
- channelId: grant.channel_id,
299
+ channelId,
231
300
  suite: grant.default_suite,
232
- psk: assertValidPSK(grant.e2ee_psk_b64u, "tunnel"),
301
+ psk,
233
302
  initExpireAtUnixS: grant.channel_init_expire_at_unix_s,
234
303
  }, options, endpointInstanceId);
235
304
  }
236
305
  catch (error) {
237
- transport.close();
306
+ transport?.close();
238
307
  if (error instanceof FlowersecError)
239
308
  throw error;
309
+ if (error instanceof TimeoutError) {
310
+ throw new FlowersecError({ path: "tunnel", stage: "connect", code: "timeout", message: "endpoint tunnel connect timed out", cause: error });
311
+ }
312
+ if (error instanceof AbortError) {
313
+ throw new FlowersecError({ path: "tunnel", stage: "connect", code: "canceled", message: "endpoint tunnel connect canceled", cause: error });
314
+ }
240
315
  throw new FlowersecError({ path: "tunnel", stage: "connect", code: "dial_failed", message: "endpoint tunnel connect failed", cause: error });
241
316
  }
242
317
  }
243
- async function establishSession(path, transport, handshake, options, endpointInstanceId) {
318
+ async function establishSession(path, transport, handshake, options, endpointInstanceId, deadline) {
244
319
  let psk;
245
320
  try {
246
321
  psk = normalizePSK(handshake.psk, path);
@@ -250,7 +325,7 @@ async function establishSession(path, transport, handshake, options, endpointIns
250
325
  throw error;
251
326
  }
252
327
  try {
253
- const secure = await serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
328
+ const startHandshake = () => serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
254
329
  channelId: prepareChannelId(handshake.channelId, path),
255
330
  suite: handshake.suite,
256
331
  psk,
@@ -262,25 +337,52 @@ async function establishSession(path, transport, handshake, options, endpointIns
262
337
  outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
263
338
  maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes,
264
339
  maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
265
- timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
340
+ timeoutMs: deadline == null
341
+ ? options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs
342
+ : remainingHandshakeTimeoutMs(deadline),
266
343
  ...(options.signal === undefined ? {} : { signal: options.signal }),
267
344
  });
345
+ const secure = deadline == null
346
+ ? await startHandshake()
347
+ : await runHandshakeStep(deadline, options.signal, startHandshake);
268
348
  try {
269
- await handshake.commitAuthenticated?.();
349
+ if (handshake.commitAuthenticated != null) {
350
+ if (deadline == null) {
351
+ await handshake.commitAuthenticated();
352
+ }
353
+ else {
354
+ await runHandshakeStep(deadline, options.signal, handshake.commitAuthenticated);
355
+ }
356
+ }
270
357
  }
271
358
  catch (error) {
272
359
  secure.close();
360
+ const interrupted = endpointHandshakeInterruption(path, error);
361
+ if (interrupted != null)
362
+ throw interrupted;
273
363
  throw new FlowersecError({ path, stage: "handshake", code: "credential_commit_failed", message: "credential commit failed", cause: error });
274
364
  }
275
- return Session.create(path, secure, {
276
- ...(options.yamuxLimits === undefined ? {} : { yamuxLimits: options.yamuxLimits }),
277
- ...(endpointInstanceId === undefined ? {} : { endpointInstanceId }),
278
- });
365
+ try {
366
+ throwIfAborted(options.signal, "handshake canceled");
367
+ if (deadline != null)
368
+ remainingHandshakeTimeoutMs(deadline);
369
+ return Session.create(path, secure, {
370
+ ...(options.yamuxLimits === undefined ? {} : { yamuxLimits: options.yamuxLimits }),
371
+ ...(endpointInstanceId === undefined ? {} : { endpointInstanceId }),
372
+ });
373
+ }
374
+ catch (error) {
375
+ secure.close();
376
+ throw error;
377
+ }
279
378
  }
280
379
  catch (error) {
281
380
  transport.close();
282
381
  if (error instanceof FlowersecError)
283
382
  throw error;
383
+ const interrupted = endpointHandshakeInterruption(path, error);
384
+ if (interrupted != null)
385
+ throw interrupted;
284
386
  throw new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message: "endpoint handshake failed", cause: error });
285
387
  }
286
388
  finally {
@@ -352,22 +454,25 @@ function unwrapServerGrant(input) {
352
454
  function webSocketTransportOptions(options) {
353
455
  return options.webSocketLimits === undefined ? {} : { webSocketLimits: options.webSocketLimits };
354
456
  }
355
- function readOptions(options) {
356
- return {
357
- timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
358
- ...(options.signal === undefined ? {} : { signal: options.signal }),
359
- };
360
- }
361
457
  async function enforceIncomingDirectTransport(options) {
362
458
  const rawUrl = options.secureTransport === false ? "ws://127.0.0.1/" : "wss://127.0.0.1/";
363
459
  await enforceTransportSecurity({ rawUrl, path: "direct", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
364
460
  }
365
461
  function waitForOpen(websocket, timeoutMs, signal) {
366
- if (websocket.readyState === 1)
367
- return Promise.resolve();
368
462
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
369
463
  return Promise.reject(new RangeError("connectTimeoutMs must be non-negative"));
464
+ try {
465
+ throwIfAborted(signal, "connect aborted");
466
+ }
467
+ catch (error) {
468
+ return Promise.reject(error);
469
+ }
470
+ if (websocket.readyState === 1)
471
+ return Promise.resolve();
472
+ if (websocket.readyState >= 2)
473
+ return Promise.reject(new Error("websocket closed before open"));
370
474
  return new Promise((resolve, reject) => {
475
+ let settled = false;
371
476
  let timer;
372
477
  const cleanup = () => {
373
478
  if (timer != null)
@@ -378,6 +483,9 @@ function waitForOpen(websocket, timeoutMs, signal) {
378
483
  signal?.removeEventListener("abort", onAbort);
379
484
  };
380
485
  const finish = (error) => {
486
+ if (settled)
487
+ return;
488
+ settled = true;
381
489
  cleanup();
382
490
  if (error == null)
383
491
  resolve();
@@ -392,14 +500,58 @@ function waitForOpen(websocket, timeoutMs, signal) {
392
500
  websocket.addEventListener("error", onError);
393
501
  websocket.addEventListener("close", onClose);
394
502
  signal?.addEventListener("abort", onAbort, { once: true });
395
- if (timeoutMs > 0)
396
- timer = setTimeout(() => finish(new Error("websocket open timeout")), timeoutMs);
503
+ if (signal?.aborted) {
504
+ onAbort();
505
+ }
506
+ else if (websocket.readyState === 1) {
507
+ onOpen();
508
+ }
509
+ else if (websocket.readyState >= 2) {
510
+ onClose();
511
+ }
512
+ if (!settled && timeoutMs > 0)
513
+ timer = setTimeout(() => finish(new TimeoutError("websocket open timeout")), timeoutMs);
397
514
  });
398
515
  }
399
516
  function cleanupWaiter(waiter) {
400
517
  if (waiter.onAbort != null)
401
518
  waiter.signal?.removeEventListener("abort", waiter.onAbort);
402
519
  }
520
+ function createHandshakeDeadline(timeoutMs) {
521
+ return timeoutMs > 0 ? { expiresAtMs: Date.now() + timeoutMs } : {};
522
+ }
523
+ function remainingHandshakeTimeoutMs(deadline) {
524
+ if (deadline.expiresAtMs === undefined)
525
+ return 0;
526
+ const remaining = deadline.expiresAtMs - Date.now();
527
+ if (remaining <= 0)
528
+ throw new TimeoutError("handshake timeout");
529
+ return remaining;
530
+ }
531
+ async function runHandshakeStep(deadline, signal, operation) {
532
+ throwIfAborted(signal, "handshake canceled");
533
+ const timeoutMs = remainingHandshakeTimeoutMs(deadline);
534
+ const result = await withAbortAndTimeout(Promise.resolve().then(operation), {
535
+ timeoutMs,
536
+ ...(signal === undefined ? {} : { signal }),
537
+ });
538
+ throwIfAborted(signal, "handshake canceled");
539
+ remainingHandshakeTimeoutMs(deadline);
540
+ return result;
541
+ }
542
+ function endpointHandshakeInterruption(path, error) {
543
+ if (error instanceof TimeoutError) {
544
+ return new FlowersecError({ path, stage: "handshake", code: "timeout", message: "endpoint handshake timed out", cause: error });
545
+ }
546
+ if (error instanceof AbortError) {
547
+ return new FlowersecError({ path, stage: "handshake", code: "canceled", message: "endpoint handshake canceled", cause: error });
548
+ }
549
+ return undefined;
550
+ }
551
+ function endpointHandshakeError(path, error, message) {
552
+ return endpointHandshakeInterruption(path, error)
553
+ ?? new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message, cause: error });
554
+ }
403
555
  function asError(error) {
404
556
  return error instanceof Error ? error : new Error(String(error));
405
557
  }
package/dist/facade.d.ts CHANGED
@@ -17,7 +17,7 @@ export type { WebSocketLimits } from "./ws-client/binaryTransport.js";
17
17
  export type { YamuxLimits } from "./yamux/session.js";
18
18
  export type { FlowersecErrorCode, FlowersecPath, FlowersecStage } from "./utils/errors.js";
19
19
  export { FlowersecError } from "./utils/errors.js";
20
- export { AllowPlaintext, AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "./client-connect/transportSecurity.js";
20
+ export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "./client-connect/transportSecurity.js";
21
21
  export type { NetworkPlaintextPolicyOptions, TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, } from "./client-connect/transportSecurity.js";
22
22
  export type { TunnelConnectOptions } from "./tunnel-client/connect.js";
23
23
  export type { DirectConnectOptions } from "./direct-client/connect.js";
package/dist/facade.js CHANGED
@@ -6,7 +6,7 @@ export { assertChannelInitGrant } from "./gen/flowersec/controlplane/v1.gen.js";
6
6
  export { assertDirectConnectInfo } from "./gen/flowersec/direct/v1.gen.js";
7
7
  export { assertConnectArtifact } from "./connect/artifact.js";
8
8
  export { FlowersecError } from "./utils/errors.js";
9
- export { AllowPlaintext, AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "./client-connect/transportSecurity.js";
9
+ export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "./client-connect/transportSecurity.js";
10
10
  export async function connectTunnel(grant, opts) {
11
11
  return await connectTunnelInternal(grant, opts);
12
12
  }
@@ -1,5 +1,5 @@
1
1
  export { createNodeWsFactory } from "./wsFactory.js";
2
- export { AllowPlaintext, AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
2
+ export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
3
3
  export type { TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, NetworkPlaintextPolicyOptions, } from "../client-connect/transportSecurity.js";
4
4
  export { connectDirectNode, connectNode, connectTunnelNode } from "./connect.js";
5
5
  export type { DirectNodeReconnectConfig, NodeReconnectConfig, TunnelNodeReconnectConfig, } from "./reconnectConfig.js";
@@ -1,5 +1,5 @@
1
1
  export { createNodeWsFactory } from "./wsFactory.js";
2
- export { AllowPlaintext, AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
2
+ export { AllowPlaintextForLoopback, createNetworkPlaintextPolicy, PlaintextRiskAcceptance, RequireTLS, } from "../client-connect/transportSecurity.js";
3
3
  export { connectDirectNode, connectNode, connectTunnelNode } from "./connect.js";
4
4
  export { createDirectNodeReconnectConfig, createNodeReconnectConfig, createTunnelNodeReconnectConfig, } from "./reconnectConfig.js";
5
5
  export * from "../endpoint/index.js";