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
No known key found for this signature in database
2 changed files with 334 additions and 249 deletions

View File

@ -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);
});
});

View File

@ -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) {