refactor: split control ui gateway connect flow

This commit is contained in:
Peter Steinberger
2026-03-22 15:01:04 -07:00
parent bb3e565487
commit 44bbd2d83d
2 changed files with 334 additions and 249 deletions

View File

@@ -79,7 +79,17 @@ vi.mock("./device-identity.ts", () => ({
signDevicePayload: signDevicePayloadMock, signDevicePayload: signDevicePayloadMock,
})); }));
const { GatewayBrowserClient } = await import("./gateway.ts"); const { CONTROL_UI_OPERATOR_SCOPES, GatewayBrowserClient, shouldRetryWithDeviceToken } =
await import("./gateway.ts");
type ConnectFrame = {
id?: string;
method?: string;
params?: {
auth?: { token?: string; password?: string; deviceToken?: string };
scopes?: string[];
};
};
function createStorageMock(): Storage { function createStorageMock(): Storage {
const store = new Map<string, string>(); const store = new Map<string, string>();
@@ -119,6 +129,23 @@ function stubInsecureCrypto() {
}); });
} }
function parseLatestConnectFrame(ws: MockWebSocket): ConnectFrame {
return JSON.parse(ws.sent.at(-1) ?? "{}") as ConnectFrame;
}
async function startConnect(client: InstanceType<typeof GatewayBrowserClient>, nonce = "nonce-1") {
client.start();
const ws = getLatestWebSocket();
ws.emitOpen();
ws.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce },
});
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
return { ws, connectFrame: parseLatestConnectFrame(ws) };
}
describe("GatewayBrowserClient", () => { describe("GatewayBrowserClient", () => {
beforeEach(() => { beforeEach(() => {
const storage = createStorageMock(); const storage = createStorageMock();
@@ -143,13 +170,7 @@ describe("GatewayBrowserClient", () => {
deviceId: "device-1", deviceId: "device-1",
role: "operator", role: "operator",
token: "stored-device-token", token: "stored-device-token",
scopes: [ scopes: [...CONTROL_UI_OPERATOR_SCOPES],
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
],
}); });
}); });
@@ -158,27 +179,26 @@ describe("GatewayBrowserClient", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("requests the full control ui operator scope bundle on connect", async () => {
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token",
});
const { connectFrame } = await startConnect(client);
expect(connectFrame.method).toBe("connect");
expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]);
});
it("prefers explicit shared auth over cached device tokens", async () => { it("prefers explicit shared auth over cached device tokens", async () => {
const client = new GatewayBrowserClient({ const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789", url: "ws://127.0.0.1:18789",
token: "shared-auth-token", token: "shared-auth-token",
}); });
client.start(); const { connectFrame } = await startConnect(client);
const ws = getLatestWebSocket();
ws.emitOpen();
ws.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
id?: string;
method?: string;
params?: { auth?: { token?: string } };
};
expect(typeof connectFrame.id).toBe("string"); expect(typeof connectFrame.id).toBe("string");
expect(connectFrame.method).toBe("connect"); expect(connectFrame.method).toBe("connect");
expect(connectFrame.params?.auth?.token).toBe("shared-auth-token"); expect(connectFrame.params?.auth?.token).toBe("shared-auth-token");
@@ -195,21 +215,8 @@ describe("GatewayBrowserClient", () => {
token: "shared-auth-token", token: "shared-auth-token",
}); });
client.start(); const { connectFrame } = await startConnect(client);
const ws = getLatestWebSocket();
ws.emitOpen();
ws.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
id?: string;
method?: string;
params?: { auth?: { token?: string; password?: string; deviceToken?: string } };
};
expect(connectFrame.id).toBe("req-insecure"); expect(connectFrame.id).toBe("req-insecure");
expect(connectFrame.method).toBe("connect"); expect(connectFrame.method).toBe("connect");
expect(connectFrame.params?.auth).toEqual({ expect(connectFrame.params?.auth).toEqual({
@@ -228,21 +235,8 @@ describe("GatewayBrowserClient", () => {
password: "shared-password", // pragma: allowlist secret password: "shared-password", // pragma: allowlist secret
}); });
client.start(); const { connectFrame } = await startConnect(client);
const ws = getLatestWebSocket();
ws.emitOpen();
ws.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
id?: string;
method?: string;
params?: { auth?: { token?: string; password?: string; deviceToken?: string } };
};
expect(connectFrame.id).toBe("req-insecure"); expect(connectFrame.id).toBe("req-insecure");
expect(connectFrame.method).toBe("connect"); expect(connectFrame.method).toBe("connect");
expect(connectFrame.params?.auth).toEqual({ expect(connectFrame.params?.auth).toEqual({
@@ -259,21 +253,8 @@ describe("GatewayBrowserClient", () => {
url: "ws://127.0.0.1:18789", url: "ws://127.0.0.1:18789",
}); });
client.start(); const { connectFrame } = await startConnect(client);
const ws = getLatestWebSocket();
ws.emitOpen();
ws.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
id?: string;
method?: string;
params?: { auth?: { token?: string } };
};
expect(typeof connectFrame.id).toBe("string"); expect(typeof connectFrame.id).toBe("string");
expect(connectFrame.method).toBe("connect"); expect(connectFrame.method).toBe("connect");
expect(connectFrame.params?.auth?.token).toBe("stored-device-token"); expect(connectFrame.params?.auth?.token).toBe("stored-device-token");
@@ -289,19 +270,7 @@ describe("GatewayBrowserClient", () => {
token: "shared-auth-token", token: "shared-auth-token",
}); });
client.start(); const { ws: ws1, connectFrame: firstConnect } = await startConnect(client);
const ws1 = getLatestWebSocket();
ws1.emitOpen();
ws1.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0));
const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as {
id: string;
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(firstConnect.params?.auth?.token).toBe("shared-auth-token"); expect(firstConnect.params?.auth?.token).toBe("shared-auth-token");
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined(); expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
@@ -328,10 +297,7 @@ describe("GatewayBrowserClient", () => {
payload: { nonce: "nonce-2" }, payload: { nonce: "nonce-2" },
}); });
await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0)); await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0));
const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as { const secondConnect = parseLatestConnectFrame(ws2);
id: string;
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(secondConnect.params?.auth?.token).toBe("shared-auth-token"); expect(secondConnect.params?.auth?.token).toBe("shared-auth-token");
expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token"); expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token");
@@ -363,19 +329,7 @@ describe("GatewayBrowserClient", () => {
token: "shared-auth-token", token: "shared-auth-token",
}); });
client.start(); const { ws: ws1, connectFrame: firstConnect } = await startConnect(client);
const ws1 = getLatestWebSocket();
ws1.emitOpen();
ws1.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0));
const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as {
id: string;
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(firstConnect.params?.auth?.token).toBe("shared-auth-token"); expect(firstConnect.params?.auth?.token).toBe("shared-auth-token");
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined(); expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
@@ -402,9 +356,7 @@ describe("GatewayBrowserClient", () => {
payload: { nonce: "nonce-2" }, payload: { nonce: "nonce-2" },
}); });
await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0)); await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0));
const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as { const secondConnect = parseLatestConnectFrame(ws2);
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(secondConnect.params?.auth?.token).toBe("shared-auth-token"); expect(secondConnect.params?.auth?.token).toBe("shared-auth-token");
expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token"); expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token");
@@ -421,16 +373,7 @@ describe("GatewayBrowserClient", () => {
token: "shared-auth-token", token: "shared-auth-token",
}); });
client.start(); const { ws: ws1, connectFrame: firstConnect } = await startConnect(client);
const ws1 = getLatestWebSocket();
ws1.emitOpen();
ws1.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0));
const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { id: string };
ws1.emitMessage({ ws1.emitMessage({
type: "res", type: "res",
@@ -460,16 +403,7 @@ describe("GatewayBrowserClient", () => {
url: "ws://127.0.0.1:18789", url: "ws://127.0.0.1:18789",
}); });
client.start(); const { ws: ws1, connectFrame: connect } = await startConnect(client);
const ws1 = getLatestWebSocket();
ws1.emitOpen();
ws1.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0));
const connect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { id: string };
ws1.emitMessage({ ws1.emitMessage({
type: "res", type: "res",
@@ -490,3 +424,41 @@ describe("GatewayBrowserClient", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
describe("shouldRetryWithDeviceToken", () => {
it("allows a bounded retry for trusted loopback endpoints", () => {
expect(
shouldRetryWithDeviceToken({
deviceTokenRetryBudgetUsed: false,
authDeviceToken: undefined,
explicitGatewayToken: "shared-auth-token",
deviceIdentity: {
deviceId: "device-1",
privateKey: "private-key", // pragma: allowlist secret
publicKey: "public-key", // pragma: allowlist secret
},
storedToken: "stored-device-token",
canRetryWithDeviceTokenHint: true,
url: "ws://127.0.0.1:18789",
}),
).toBe(true);
});
it("blocks the retry after the one-shot budget is spent", () => {
expect(
shouldRetryWithDeviceToken({
deviceTokenRetryBudgetUsed: true,
authDeviceToken: undefined,
explicitGatewayToken: "shared-auth-token",
deviceIdentity: {
deviceId: "device-1",
privateKey: "private-key", // pragma: allowlist secret
publicKey: "public-key", // pragma: allowlist secret
},
storedToken: "stored-device-token",
canRetryWithDeviceTokenHint: true,
url: "ws://127.0.0.1:18789",
}),
).toBe(false);
});
});

View File

@@ -128,6 +128,72 @@ type SelectedConnectAuth = {
canFallbackToShared: boolean; canFallbackToShared: boolean;
}; };
export const CONTROL_UI_OPERATOR_ROLE = "operator";
export const CONTROL_UI_OPERATOR_SCOPES = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
] as const;
export type GatewayConnectAuth = {
token?: string;
deviceToken?: string;
password?: string;
};
export type GatewayConnectDevice = {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string;
};
export type GatewayConnectClientInfo = {
id: GatewayClientName;
version: string;
platform: string;
mode: GatewayClientMode;
instanceId?: string;
};
export type GatewayConnectParams = {
minProtocol: 3;
maxProtocol: 3;
client: GatewayConnectClientInfo;
role: string;
scopes: string[];
device?: GatewayConnectDevice;
caps: string[];
auth?: GatewayConnectAuth;
userAgent: string;
locale: string;
};
type ConnectPlan = {
role: string;
scopes: string[];
client: GatewayConnectClientInfo;
explicitGatewayToken?: string;
selectedAuth: SelectedConnectAuth;
auth?: GatewayConnectAuth;
deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null;
device?: GatewayConnectDevice;
};
type DeviceTokenRetryDecision = {
deviceTokenRetryBudgetUsed: boolean;
authDeviceToken?: string;
explicitGatewayToken?: string;
deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null;
storedToken?: string;
canRetryWithDeviceTokenHint: boolean;
url: string;
};
export type GatewayBrowserClientOptions = { export type GatewayBrowserClientOptions = {
url: string; url: string;
token?: string; token?: string;
@@ -146,6 +212,66 @@ export type GatewayBrowserClientOptions = {
// 4008 = application-defined code (browser rejects 1008 "Policy Violation") // 4008 = application-defined code (browser rejects 1008 "Policy Violation")
const CONNECT_FAILED_CLOSE_CODE = 4008; const CONNECT_FAILED_CLOSE_CODE = 4008;
function buildGatewayConnectAuth(
selectedAuth: SelectedConnectAuth,
): GatewayConnectAuth | undefined {
const authToken = selectedAuth.authToken;
if (!(authToken || selectedAuth.authPassword)) {
return undefined;
}
return {
token: authToken,
deviceToken: selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken,
password: selectedAuth.authPassword,
};
}
async function buildGatewayConnectDevice(params: {
deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null;
client: GatewayConnectClientInfo;
role: string;
scopes: string[];
authToken?: string;
connectNonce: string | null;
}): Promise<GatewayConnectDevice | undefined> {
const { deviceIdentity } = params;
if (!deviceIdentity) {
return undefined;
}
const signedAtMs = Date.now();
const nonce = params.connectNonce ?? "";
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: params.client.id,
clientMode: params.client.mode,
role: params.role,
scopes: params.scopes,
signedAtMs,
token: params.authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
return {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
export function shouldRetryWithDeviceToken(params: DeviceTokenRetryDecision): boolean {
return (
!params.deviceTokenRetryBudgetUsed &&
!params.authDeviceToken &&
Boolean(params.explicitGatewayToken) &&
Boolean(params.deviceIdentity) &&
Boolean(params.storedToken) &&
params.canRetryWithDeviceTokenHint &&
isTrustedRetryEndpoint(params.url)
);
}
export class GatewayBrowserClient { export class GatewayBrowserClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private pending = new Map<string, Pending>(); private pending = new Map<string, Pending>();
@@ -227,31 +353,42 @@ export class GatewayBrowserClient {
this.pending.clear(); this.pending.clear();
} }
private async sendConnect() { private buildConnectClient(): GatewayConnectClientInfo {
if (this.connectSent) { return {
return; id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
} version: this.opts.clientVersion ?? "control-ui",
this.connectSent = true; platform: this.opts.platform ?? navigator.platform ?? "web",
if (this.connectTimer !== null) { mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
window.clearTimeout(this.connectTimer); instanceId: this.opts.instanceId,
this.connectTimer = null; };
} }
private buildConnectParams(plan: ConnectPlan): GatewayConnectParams {
return {
minProtocol: 3,
maxProtocol: 3,
client: plan.client,
role: plan.role,
scopes: plan.scopes,
device: plan.device,
caps: ["tool-events"],
auth: plan.auth,
userAgent: navigator.userAgent,
locale: navigator.language,
};
}
private async buildConnectPlan(): Promise<ConnectPlan> {
const role = CONTROL_UI_OPERATOR_ROLE;
const scopes = [...CONTROL_UI_OPERATOR_SCOPES];
const client = this.buildConnectClient();
const explicitGatewayToken = this.opts.token?.trim() || undefined;
const explicitPassword = this.opts.password?.trim() || undefined;
// crypto.subtle is only available in secure contexts (HTTPS, localhost). // crypto.subtle is only available in secure contexts (HTTPS, localhost).
// Over plain HTTP, we skip device identity and fall back to token-only auth. // Over plain HTTP, we skip device identity and fall back to token-only auth.
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled. // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
];
const role = "operator";
const explicitGatewayToken = this.opts.token?.trim() || undefined;
const explicitPassword = this.opts.password?.trim() || undefined;
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null; let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let selectedAuth: SelectedConnectAuth = { let selectedAuth: SelectedConnectAuth = {
authToken: explicitGatewayToken, authToken: explicitGatewayToken,
@@ -269,124 +406,100 @@ export class GatewayBrowserClient {
this.pendingDeviceTokenRetry = false; this.pendingDeviceTokenRetry = false;
} }
} }
const authToken = selectedAuth.authToken;
const deviceToken = selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken;
const auth =
authToken || selectedAuth.authPassword
? {
token: authToken,
deviceToken,
password: selectedAuth.authPassword,
}
: undefined;
let device: return {
| {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string;
}
| undefined;
if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? "";
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
device = {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
const params = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: this.opts.clientVersion ?? "control-ui",
platform: this.opts.platform ?? navigator.platform ?? "web",
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
instanceId: this.opts.instanceId,
},
role, role,
scopes, scopes,
device, client,
caps: ["tool-events"], explicitGatewayToken,
auth, selectedAuth,
userAgent: navigator.userAgent, auth: buildGatewayConnectAuth(selectedAuth),
locale: navigator.language, deviceIdentity,
device: await buildGatewayConnectDevice({
deviceIdentity,
client,
role,
scopes,
authToken: selectedAuth.authToken,
connectNonce: this.connectNonce,
}),
}; };
}
void this.request<GatewayHelloOk>("connect", params) private handleConnectHello(hello: GatewayHelloOk, plan: ConnectPlan) {
.then((hello) => { this.pendingDeviceTokenRetry = false;
this.pendingDeviceTokenRetry = false; this.deviceTokenRetryBudgetUsed = false;
this.deviceTokenRetryBudgetUsed = false; if (hello?.auth?.deviceToken && plan.deviceIdentity) {
if (hello?.auth?.deviceToken && deviceIdentity) { storeDeviceAuthToken({
storeDeviceAuthToken({ deviceId: plan.deviceIdentity.deviceId,
deviceId: deviceIdentity.deviceId, role: hello.auth.role ?? plan.role,
role: hello.auth.role ?? role, token: hello.auth.deviceToken,
token: hello.auth.deviceToken, scopes: hello.auth.scopes ?? [],
scopes: hello.auth.scopes ?? [],
});
}
this.backoffMs = 800;
this.opts.onHello?.(hello);
})
.catch((err: unknown) => {
const connectErrorCode =
err instanceof GatewayRequestError ? resolveGatewayErrorDetailCode(err) : null;
const recoveryAdvice =
err instanceof GatewayRequestError ? readConnectErrorRecoveryAdvice(err.details) : {};
const retryWithDeviceTokenRecommended =
recoveryAdvice.recommendedNextStep === "retry_with_device_token";
const canRetryWithDeviceTokenHint =
recoveryAdvice.canRetryWithDeviceToken === true ||
retryWithDeviceTokenRecommended ||
connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH;
const shouldRetryWithDeviceToken =
!this.deviceTokenRetryBudgetUsed &&
!selectedAuth.authDeviceToken &&
Boolean(explicitGatewayToken) &&
Boolean(deviceIdentity) &&
Boolean(selectedAuth.storedToken) &&
canRetryWithDeviceTokenHint &&
isTrustedRetryEndpoint(this.opts.url);
if (shouldRetryWithDeviceToken) {
this.pendingDeviceTokenRetry = true;
this.deviceTokenRetryBudgetUsed = true;
}
if (err instanceof GatewayRequestError) {
this.pendingConnectError = {
code: err.gatewayCode,
message: err.message,
details: err.details,
};
} else {
this.pendingConnectError = undefined;
}
if (
selectedAuth.canFallbackToShared &&
deviceIdentity &&
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
}); });
}
this.backoffMs = 800;
this.opts.onHello?.(hello);
}
private handleConnectFailure(err: unknown, plan: ConnectPlan) {
const connectErrorCode =
err instanceof GatewayRequestError ? resolveGatewayErrorDetailCode(err) : null;
const recoveryAdvice =
err instanceof GatewayRequestError ? readConnectErrorRecoveryAdvice(err.details) : {};
const retryWithDeviceTokenRecommended =
recoveryAdvice.recommendedNextStep === "retry_with_device_token";
const canRetryWithDeviceTokenHint =
recoveryAdvice.canRetryWithDeviceToken === true ||
retryWithDeviceTokenRecommended ||
connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH;
if (
shouldRetryWithDeviceToken({
deviceTokenRetryBudgetUsed: this.deviceTokenRetryBudgetUsed,
authDeviceToken: plan.selectedAuth.authDeviceToken,
explicitGatewayToken: plan.explicitGatewayToken,
deviceIdentity: plan.deviceIdentity,
storedToken: plan.selectedAuth.storedToken,
canRetryWithDeviceTokenHint,
url: this.opts.url,
})
) {
this.pendingDeviceTokenRetry = true;
this.deviceTokenRetryBudgetUsed = true;
}
if (err instanceof GatewayRequestError) {
this.pendingConnectError = {
code: err.gatewayCode,
message: err.message,
details: err.details,
};
} else {
this.pendingConnectError = undefined;
}
if (
plan.selectedAuth.canFallbackToShared &&
plan.deviceIdentity &&
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
) {
clearDeviceAuthToken({ deviceId: plan.deviceIdentity.deviceId, role: plan.role });
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
}
private async sendConnect() {
if (this.connectSent) {
return;
}
this.connectSent = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
const plan = await this.buildConnectPlan();
void this.request<GatewayHelloOk>("connect", this.buildConnectParams(plan))
.then((hello) => this.handleConnectHello(hello, plan))
.catch((err: unknown) => this.handleConnectFailure(err, plan));
} }
private handleMessage(raw: string) { private handleMessage(raw: string) {