mirror of https://github.com/openclaw/openclaw.git
refactor: split control ui gateway connect flow
This commit is contained in:
parent
bb3e565487
commit
44bbd2d83d
|
|
@ -79,7 +79,17 @@ vi.mock("./device-identity.ts", () => ({
|
|||
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 {
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
const storage = createStorageMock();
|
||||
|
|
@ -143,13 +170,7 @@ describe("GatewayBrowserClient", () => {
|
|||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
token: "stored-device-token",
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
],
|
||||
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -158,27 +179,26 @@ describe("GatewayBrowserClient", () => {
|
|||
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 () => {
|
||||
const client = new GatewayBrowserClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "shared-auth-token",
|
||||
});
|
||||
|
||||
client.start();
|
||||
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 } = await startConnect(client);
|
||||
|
||||
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
|
||||
id?: string;
|
||||
method?: string;
|
||||
params?: { auth?: { token?: string } };
|
||||
};
|
||||
expect(typeof connectFrame.id).toBe("string");
|
||||
expect(connectFrame.method).toBe("connect");
|
||||
expect(connectFrame.params?.auth?.token).toBe("shared-auth-token");
|
||||
|
|
@ -195,21 +215,8 @@ describe("GatewayBrowserClient", () => {
|
|||
token: "shared-auth-token",
|
||||
});
|
||||
|
||||
client.start();
|
||||
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 } = await startConnect(client);
|
||||
|
||||
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.method).toBe("connect");
|
||||
expect(connectFrame.params?.auth).toEqual({
|
||||
|
|
@ -228,21 +235,8 @@ describe("GatewayBrowserClient", () => {
|
|||
password: "shared-password", // pragma: allowlist secret
|
||||
});
|
||||
|
||||
client.start();
|
||||
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 } = await startConnect(client);
|
||||
|
||||
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.method).toBe("connect");
|
||||
expect(connectFrame.params?.auth).toEqual({
|
||||
|
|
@ -259,21 +253,8 @@ describe("GatewayBrowserClient", () => {
|
|||
url: "ws://127.0.0.1:18789",
|
||||
});
|
||||
|
||||
client.start();
|
||||
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 } = await startConnect(client);
|
||||
|
||||
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
|
||||
id?: string;
|
||||
method?: string;
|
||||
params?: { auth?: { token?: string } };
|
||||
};
|
||||
expect(typeof connectFrame.id).toBe("string");
|
||||
expect(connectFrame.method).toBe("connect");
|
||||
expect(connectFrame.params?.auth?.token).toBe("stored-device-token");
|
||||
|
|
@ -289,19 +270,7 @@ describe("GatewayBrowserClient", () => {
|
|||
token: "shared-auth-token",
|
||||
});
|
||||
|
||||
client.start();
|
||||
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 } };
|
||||
};
|
||||
const { ws: ws1, connectFrame: firstConnect } = await startConnect(client);
|
||||
expect(firstConnect.params?.auth?.token).toBe("shared-auth-token");
|
||||
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
|
||||
|
||||
|
|
@ -328,10 +297,7 @@ describe("GatewayBrowserClient", () => {
|
|||
payload: { nonce: "nonce-2" },
|
||||
});
|
||||
await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0));
|
||||
const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as {
|
||||
id: string;
|
||||
params?: { auth?: { token?: string; deviceToken?: string } };
|
||||
};
|
||||
const secondConnect = parseLatestConnectFrame(ws2);
|
||||
expect(secondConnect.params?.auth?.token).toBe("shared-auth-token");
|
||||
expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token");
|
||||
|
||||
|
|
@ -363,19 +329,7 @@ describe("GatewayBrowserClient", () => {
|
|||
token: "shared-auth-token",
|
||||
});
|
||||
|
||||
client.start();
|
||||
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 } };
|
||||
};
|
||||
const { ws: ws1, connectFrame: firstConnect } = await startConnect(client);
|
||||
expect(firstConnect.params?.auth?.token).toBe("shared-auth-token");
|
||||
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
|
||||
|
||||
|
|
@ -402,9 +356,7 @@ describe("GatewayBrowserClient", () => {
|
|||
payload: { nonce: "nonce-2" },
|
||||
});
|
||||
await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0));
|
||||
const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as {
|
||||
params?: { auth?: { token?: string; deviceToken?: string } };
|
||||
};
|
||||
const secondConnect = parseLatestConnectFrame(ws2);
|
||||
expect(secondConnect.params?.auth?.token).toBe("shared-auth-token");
|
||||
expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token");
|
||||
|
||||
|
|
@ -421,16 +373,7 @@ describe("GatewayBrowserClient", () => {
|
|||
token: "shared-auth-token",
|
||||
});
|
||||
|
||||
client.start();
|
||||
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 };
|
||||
const { ws: ws1, connectFrame: firstConnect } = await startConnect(client);
|
||||
|
||||
ws1.emitMessage({
|
||||
type: "res",
|
||||
|
|
@ -460,16 +403,7 @@ describe("GatewayBrowserClient", () => {
|
|||
url: "ws://127.0.0.1:18789",
|
||||
});
|
||||
|
||||
client.start();
|
||||
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 };
|
||||
const { ws: ws1, connectFrame: connect } = await startConnect(client);
|
||||
|
||||
ws1.emitMessage({
|
||||
type: "res",
|
||||
|
|
@ -490,3 +424,41 @@ describe("GatewayBrowserClient", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,6 +128,72 @@ type SelectedConnectAuth = {
|
|||
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 = {
|
||||
url: string;
|
||||
token?: string;
|
||||
|
|
@ -146,6 +212,66 @@ export type GatewayBrowserClientOptions = {
|
|||
// 4008 = application-defined code (browser rejects 1008 "Policy Violation")
|
||||
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 {
|
||||
private ws: WebSocket | null = null;
|
||||
private pending = new Map<string, Pending>();
|
||||
|
|
@ -227,31 +353,42 @@ export class GatewayBrowserClient {
|
|||
this.pending.clear();
|
||||
}
|
||||
|
||||
private async sendConnect() {
|
||||
if (this.connectSent) {
|
||||
return;
|
||||
}
|
||||
this.connectSent = true;
|
||||
if (this.connectTimer !== null) {
|
||||
window.clearTimeout(this.connectTimer);
|
||||
this.connectTimer = null;
|
||||
}
|
||||
private buildConnectClient(): GatewayConnectClientInfo {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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).
|
||||
// Over plain HTTP, we skip device identity and fall back to token-only auth.
|
||||
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
|
||||
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 selectedAuth: SelectedConnectAuth = {
|
||||
authToken: explicitGatewayToken,
|
||||
|
|
@ -269,124 +406,100 @@ export class GatewayBrowserClient {
|
|||
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:
|
||||
| {
|
||||
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,
|
||||
},
|
||||
return {
|
||||
role,
|
||||
scopes,
|
||||
device,
|
||||
caps: ["tool-events"],
|
||||
auth,
|
||||
userAgent: navigator.userAgent,
|
||||
locale: navigator.language,
|
||||
client,
|
||||
explicitGatewayToken,
|
||||
selectedAuth,
|
||||
auth: buildGatewayConnectAuth(selectedAuth),
|
||||
deviceIdentity,
|
||||
device: await buildGatewayConnectDevice({
|
||||
deviceIdentity,
|
||||
client,
|
||||
role,
|
||||
scopes,
|
||||
authToken: selectedAuth.authToken,
|
||||
connectNonce: this.connectNonce,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
void this.request<GatewayHelloOk>("connect", params)
|
||||
.then((hello) => {
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
if (hello?.auth?.deviceToken && deviceIdentity) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role: hello.auth.role ?? role,
|
||||
token: hello.auth.deviceToken,
|
||||
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");
|
||||
private handleConnectHello(hello: GatewayHelloOk, plan: ConnectPlan) {
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
if (hello?.auth?.deviceToken && plan.deviceIdentity) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: plan.deviceIdentity.deviceId,
|
||||
role: hello.auth.role ?? plan.role,
|
||||
token: hello.auth.deviceToken,
|
||||
scopes: hello.auth.scopes ?? [],
|
||||
});
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue