mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 01:51:24 +00:00
fix: harden connect auth flow and exec policy diagnostics
This commit is contained in:
66
src/gateway/protocol/connect-error-details.ts
Normal file
66
src/gateway/protocol/connect-error-details.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export const ConnectErrorDetailCodes = {
|
||||
AUTH_REQUIRED: "AUTH_REQUIRED",
|
||||
AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
|
||||
AUTH_TOKEN_MISSING: "AUTH_TOKEN_MISSING",
|
||||
AUTH_TOKEN_MISMATCH: "AUTH_TOKEN_MISMATCH",
|
||||
AUTH_TOKEN_NOT_CONFIGURED: "AUTH_TOKEN_NOT_CONFIGURED",
|
||||
AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING",
|
||||
AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH",
|
||||
AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED",
|
||||
AUTH_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH",
|
||||
AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED",
|
||||
AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING",
|
||||
AUTH_TAILSCALE_PROXY_MISSING: "AUTH_TAILSCALE_PROXY_MISSING",
|
||||
AUTH_TAILSCALE_WHOIS_FAILED: "AUTH_TAILSCALE_WHOIS_FAILED",
|
||||
AUTH_TAILSCALE_IDENTITY_MISMATCH: "AUTH_TAILSCALE_IDENTITY_MISMATCH",
|
||||
CONTROL_UI_DEVICE_IDENTITY_REQUIRED: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
|
||||
DEVICE_IDENTITY_REQUIRED: "DEVICE_IDENTITY_REQUIRED",
|
||||
DEVICE_AUTH_INVALID: "DEVICE_AUTH_INVALID",
|
||||
PAIRING_REQUIRED: "PAIRING_REQUIRED",
|
||||
} as const;
|
||||
|
||||
export type ConnectErrorDetailCode =
|
||||
(typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes];
|
||||
|
||||
export function resolveAuthConnectErrorDetailCode(
|
||||
reason: string | undefined,
|
||||
): ConnectErrorDetailCode {
|
||||
switch (reason) {
|
||||
case "token_missing":
|
||||
return ConnectErrorDetailCodes.AUTH_TOKEN_MISSING;
|
||||
case "token_mismatch":
|
||||
return ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH;
|
||||
case "token_missing_config":
|
||||
return ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED;
|
||||
case "password_missing":
|
||||
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING;
|
||||
case "password_mismatch":
|
||||
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH;
|
||||
case "password_missing_config":
|
||||
return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED;
|
||||
case "tailscale_user_missing":
|
||||
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING;
|
||||
case "tailscale_proxy_missing":
|
||||
return ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING;
|
||||
case "tailscale_whois_failed":
|
||||
return ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED;
|
||||
case "tailscale_user_mismatch":
|
||||
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH;
|
||||
case "rate_limited":
|
||||
return ConnectErrorDetailCodes.AUTH_RATE_LIMITED;
|
||||
case "device_token_mismatch":
|
||||
return ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH;
|
||||
case undefined:
|
||||
return ConnectErrorDetailCodes.AUTH_REQUIRED;
|
||||
default:
|
||||
return ConnectErrorDetailCodes.AUTH_UNAUTHORIZED;
|
||||
}
|
||||
}
|
||||
|
||||
export function readConnectErrorDetailCode(details: unknown): string | null {
|
||||
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
||||
return null;
|
||||
}
|
||||
const code = (details as { code?: unknown }).code;
|
||||
return typeof code === "string" && code.trim().length > 0 ? code : null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { WebSocket } from "ws";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||
import { ConnectErrorDetailCodes } from "./protocol/connect-error-details.js";
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import { getHandshakeTimeoutMs } from "./server-constants.js";
|
||||
import {
|
||||
@@ -716,6 +717,9 @@ describe("gateway server auth/connect", () => {
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("secure context");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
||||
);
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
@@ -898,6 +902,9 @@ describe("gateway server auth/connect", () => {
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("pairing required");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
);
|
||||
ws.close();
|
||||
});
|
||||
} finally {
|
||||
@@ -1004,6 +1011,9 @@ describe("gateway server auth/connect", () => {
|
||||
expect(res2.ok).toBe(false);
|
||||
expect(res2.error?.message ?? "").toContain("gateway token mismatch");
|
||||
expect(res2.error?.message ?? "").not.toContain("device token mismatch");
|
||||
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
|
||||
);
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
@@ -1023,6 +1033,9 @@ describe("gateway server auth/connect", () => {
|
||||
});
|
||||
expect(res2.ok).toBe(false);
|
||||
expect(res2.error?.message ?? "").toContain("device token mismatch");
|
||||
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
|
||||
);
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
|
||||
124
src/gateway/server/ws-connection/auth-context.test.ts
Normal file
124
src/gateway/server/ws-connection/auth-context.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
|
||||
|
||||
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
|
||||
limiter: AuthRateLimiter;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const allowed = params?.allowed ?? true;
|
||||
const retryAfterMs = params?.retryAfterMs ?? 5_000;
|
||||
const check = vi.fn(() => ({ allowed, retryAfterMs }));
|
||||
const reset = vi.fn();
|
||||
const recordFailure = vi.fn();
|
||||
return {
|
||||
limiter: {
|
||||
check,
|
||||
reset,
|
||||
recordFailure,
|
||||
} as unknown as AuthRateLimiter,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(overrides?: Partial<ConnectAuthState>): ConnectAuthState {
|
||||
return {
|
||||
authResult: { ok: false, reason: "token_mismatch" },
|
||||
authOk: false,
|
||||
authMethod: "token",
|
||||
sharedAuthOk: false,
|
||||
sharedAuthProvided: true,
|
||||
deviceTokenCandidate: "device-token",
|
||||
deviceTokenCandidateSource: "shared-token-fallback",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveConnectAuthDecision", () => {
|
||||
it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
|
||||
const decision = await resolveConnectAuthDecision({
|
||||
state: createBaseState(),
|
||||
hasDeviceIdentity: true,
|
||||
deviceId: "dev-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
verifyDeviceToken,
|
||||
});
|
||||
expect(decision.authOk).toBe(false);
|
||||
expect(decision.authResult.reason).toBe("token_mismatch");
|
||||
expect(verifyDeviceToken).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports explicit device-token mismatches as device_token_mismatch", async () => {
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
|
||||
const decision = await resolveConnectAuthDecision({
|
||||
state: createBaseState({
|
||||
deviceTokenCandidateSource: "explicit-device-token",
|
||||
}),
|
||||
hasDeviceIdentity: true,
|
||||
deviceId: "dev-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
verifyDeviceToken,
|
||||
});
|
||||
expect(decision.authOk).toBe(false);
|
||||
expect(decision.authResult.reason).toBe("device_token_mismatch");
|
||||
});
|
||||
|
||||
it("accepts valid device tokens and marks auth method as device-token", async () => {
|
||||
const rateLimiter = createRateLimiter();
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
|
||||
const decision = await resolveConnectAuthDecision({
|
||||
state: createBaseState(),
|
||||
hasDeviceIdentity: true,
|
||||
deviceId: "dev-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
rateLimiter: rateLimiter.limiter,
|
||||
clientIp: "203.0.113.20",
|
||||
verifyDeviceToken,
|
||||
});
|
||||
expect(decision.authOk).toBe(true);
|
||||
expect(decision.authMethod).toBe("device-token");
|
||||
expect(verifyDeviceToken).toHaveBeenCalledOnce();
|
||||
expect(rateLimiter.reset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns rate-limited auth result without verifying device token", async () => {
|
||||
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
|
||||
const decision = await resolveConnectAuthDecision({
|
||||
state: createBaseState(),
|
||||
hasDeviceIdentity: true,
|
||||
deviceId: "dev-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
rateLimiter: rateLimiter.limiter,
|
||||
clientIp: "203.0.113.20",
|
||||
verifyDeviceToken,
|
||||
});
|
||||
expect(decision.authOk).toBe(false);
|
||||
expect(decision.authResult.reason).toBe("rate_limited");
|
||||
expect(decision.authResult.retryAfterMs).toBe(60_000);
|
||||
expect(verifyDeviceToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the original decision when device fallback does not apply", async () => {
|
||||
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
|
||||
const decision = await resolveConnectAuthDecision({
|
||||
state: createBaseState({
|
||||
authResult: { ok: true, method: "token" },
|
||||
authOk: true,
|
||||
}),
|
||||
hasDeviceIdentity: true,
|
||||
deviceId: "dev-1",
|
||||
role: "operator",
|
||||
scopes: [],
|
||||
verifyDeviceToken,
|
||||
});
|
||||
expect(decision.authOk).toBe(true);
|
||||
expect(decision.authMethod).toBe("token");
|
||||
expect(verifyDeviceToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import {
|
||||
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
|
||||
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
||||
type AuthRateLimiter,
|
||||
type RateLimitCheckResult,
|
||||
@@ -29,6 +30,14 @@ export type ConnectAuthState = {
|
||||
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
|
||||
};
|
||||
|
||||
type VerifyDeviceTokenResult = { ok: boolean };
|
||||
|
||||
export type ConnectAuthDecision = {
|
||||
authResult: GatewayAuthResult;
|
||||
authOk: boolean;
|
||||
authMethod: GatewayAuthResult["method"];
|
||||
};
|
||||
|
||||
function trimToUndefined(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
@@ -139,3 +148,67 @@ export async function resolveConnectAuthState(params: {
|
||||
deviceTokenCandidateSource,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveConnectAuthDecision(params: {
|
||||
state: ConnectAuthState;
|
||||
hasDeviceIdentity: boolean;
|
||||
deviceId?: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
clientIp?: string;
|
||||
verifyDeviceToken: (params: {
|
||||
deviceId: string;
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
}) => Promise<VerifyDeviceTokenResult>;
|
||||
}): Promise<ConnectAuthDecision> {
|
||||
let authResult = params.state.authResult;
|
||||
let authOk = params.state.authOk;
|
||||
let authMethod = params.state.authMethod;
|
||||
|
||||
const deviceTokenCandidate = params.state.deviceTokenCandidate;
|
||||
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
|
||||
return { authResult, authOk, authMethod };
|
||||
}
|
||||
|
||||
if (params.rateLimiter) {
|
||||
const deviceRateCheck = params.rateLimiter.check(
|
||||
params.clientIp,
|
||||
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
|
||||
);
|
||||
if (!deviceRateCheck.allowed) {
|
||||
authResult = {
|
||||
ok: false,
|
||||
reason: "rate_limited",
|
||||
rateLimited: true,
|
||||
retryAfterMs: deviceRateCheck.retryAfterMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!authResult.rateLimited) {
|
||||
const tokenCheck = await params.verifyDeviceToken({
|
||||
deviceId: params.deviceId,
|
||||
token: deviceTokenCandidate,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
});
|
||||
if (tokenCheck.ok) {
|
||||
authOk = true;
|
||||
authMethod = "device-token";
|
||||
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
|
||||
} else {
|
||||
authResult = {
|
||||
ok: false,
|
||||
reason:
|
||||
params.state.deviceTokenCandidateSource === "explicit-device-token"
|
||||
? "device_token_mismatch"
|
||||
: (authResult.reason ?? "device_token_mismatch"),
|
||||
};
|
||||
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
return { authResult, authOk, authMethod };
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
||||
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
||||
import { AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, type AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { isLocalDirectRequest } from "../../auth.js";
|
||||
import {
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||
import { checkBrowserOrigin } from "../../origin-check.js";
|
||||
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
|
||||
import {
|
||||
ConnectErrorDetailCodes,
|
||||
resolveAuthConnectErrorDetailCode,
|
||||
} from "../../protocol/connect-error-details.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
ErrorCodes,
|
||||
@@ -67,7 +71,7 @@ import {
|
||||
refreshGatewayHealthSnapshot,
|
||||
} from "../health-state.js";
|
||||
import type { GatewayWsClient } from "../ws-types.js";
|
||||
import { resolveConnectAuthState } from "./auth-context.js";
|
||||
import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-context.js";
|
||||
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
|
||||
import {
|
||||
evaluateMissingDeviceIdentity,
|
||||
@@ -401,7 +405,12 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
reason: failedAuth.reason,
|
||||
client: connectParams.client,
|
||||
});
|
||||
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage);
|
||||
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage, {
|
||||
details: {
|
||||
code: resolveAuthConnectErrorDetailCode(failedAuth.reason),
|
||||
authReason: failedAuth.reason,
|
||||
},
|
||||
});
|
||||
close(1008, truncateCloseReason(authMessage));
|
||||
};
|
||||
const clearUnboundScopes = () => {
|
||||
@@ -434,7 +443,9 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
markHandshakeFailure("control-ui-insecure-auth", {
|
||||
insecureAuthConfigured: controlUiAuthPolicy.allowInsecureAuthConfigured,
|
||||
});
|
||||
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage);
|
||||
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, {
|
||||
details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED },
|
||||
});
|
||||
close(1008, errorMessage);
|
||||
return false;
|
||||
}
|
||||
@@ -445,7 +456,9 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}
|
||||
|
||||
markHandshakeFailure("device-required");
|
||||
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required");
|
||||
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required", {
|
||||
details: { code: ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED },
|
||||
});
|
||||
close(1008, "device identity required");
|
||||
return false;
|
||||
};
|
||||
@@ -464,7 +477,12 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, message),
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, message, {
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.DEVICE_AUTH_INVALID,
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
close(1008, message);
|
||||
};
|
||||
@@ -514,39 +532,24 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!authOk && device && deviceTokenCandidate) {
|
||||
if (rateLimiter) {
|
||||
const deviceRateCheck = rateLimiter.check(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
|
||||
if (!deviceRateCheck.allowed) {
|
||||
authResult = {
|
||||
ok: false,
|
||||
reason: "rate_limited",
|
||||
rateLimited: true,
|
||||
retryAfterMs: deviceRateCheck.retryAfterMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!authResult.rateLimited) {
|
||||
const tokenCheck = await verifyDeviceToken({
|
||||
deviceId: device.id,
|
||||
token: deviceTokenCandidate,
|
||||
role,
|
||||
scopes,
|
||||
});
|
||||
if (tokenCheck.ok) {
|
||||
authOk = true;
|
||||
authMethod = "device-token";
|
||||
rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
|
||||
} else {
|
||||
const mismatchReason =
|
||||
deviceTokenCandidateSource === "explicit-device-token"
|
||||
? "device_token_mismatch"
|
||||
: (authResult.reason ?? "device_token_mismatch");
|
||||
authResult = { ok: false, reason: mismatchReason };
|
||||
rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
({ authResult, authOk, authMethod } = await resolveConnectAuthDecision({
|
||||
state: {
|
||||
authResult,
|
||||
authOk,
|
||||
authMethod,
|
||||
sharedAuthOk,
|
||||
sharedAuthProvided: hasSharedAuth,
|
||||
deviceTokenCandidate,
|
||||
deviceTokenCandidateSource,
|
||||
},
|
||||
hasDeviceIdentity: Boolean(device),
|
||||
deviceId: device?.id,
|
||||
role,
|
||||
scopes,
|
||||
rateLimiter,
|
||||
clientIp,
|
||||
verifyDeviceToken,
|
||||
}));
|
||||
if (!authOk) {
|
||||
rejectUnauthorized(authResult);
|
||||
return;
|
||||
@@ -636,7 +639,11 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", {
|
||||
details: { requestId: pairing.request.requestId },
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
requestId: pairing.request.requestId,
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
close(1008, "pairing required");
|
||||
|
||||
@@ -409,7 +409,7 @@ type ConnectResponse = {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: Record<string, unknown>;
|
||||
error?: { message?: string };
|
||||
error?: { message?: string; code?: string; details?: unknown };
|
||||
};
|
||||
|
||||
export async function readConnectChallengeNonce(
|
||||
|
||||
Reference in New Issue
Block a user