mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +00:00
refactor: split control ui gateway connect flow
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user