From 081b1aa1ed19e89c3211fdc4dcad07a38adb4982 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 15:08:40 +0100 Subject: [PATCH] refactor(gateway): unify v3 auth payload builders and vectors --- .../android/gateway/DeviceAuthPayload.kt | 52 +++++++ .../android/gateway/GatewaySession.kt | 38 +---- .../android/gateway/DeviceAuthPayloadTest.kt | 35 +++++ .../OpenClawMacCLI/WizardCommand.swift | 40 +---- .../OpenClawKit/DeviceAuthPayload.swift | 55 +++++++ .../Sources/OpenClawKit/GatewayChannel.swift | 40 +---- .../DeviceAuthPayloadTests.swift | 30 ++++ src/gateway/device-auth.test.ts | 29 ++++ src/gateway/device-auth.ts | 18 ++- .../server/ws-connection/message-handler.ts | 144 ++++++++++++------ 10 files changed, 313 insertions(+), 168 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift create mode 100644 src/gateway/device-auth.test.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt new file mode 100644 index 00000000000..9fecaa03b55 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt @@ -0,0 +1,52 @@ +package ai.openclaw.android.gateway + +internal object DeviceAuthPayload { + fun buildV3( + deviceId: String, + clientId: String, + clientMode: String, + role: String, + scopes: List, + signedAtMs: Long, + token: String?, + nonce: String, + platform: String?, + deviceFamily: String?, + ): String { + val scopeString = scopes.joinToString(",") + val authToken = token.orEmpty() + val platformNorm = normalizeMetadataField(platform) + val deviceFamilyNorm = normalizeMetadataField(deviceFamily) + return listOf( + "v3", + deviceId, + clientId, + clientMode, + role, + scopeString, + signedAtMs.toString(), + authToken, + nonce, + platformNorm, + deviceFamilyNorm, + ).joinToString("|") + } + + internal fun normalizeMetadataField(value: String?): String { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty()) { + return "" + } + // Keep cross-runtime normalization deterministic (TS/Swift/Kotlin): + // lowercase ASCII A-Z only for auth payload metadata fields. + val out = StringBuilder(trimmed.length) + for (ch in trimmed) { + if (ch in 'A'..'Z') { + out.append((ch.code + 32).toChar()) + } else { + out.append(ch) + } + } + return out.toString() + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index a498babaeca..7c8b13ec396 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -372,7 +372,7 @@ class GatewaySession( val signedAtMs = System.currentTimeMillis() val payload = - buildDeviceAuthPayloadV3( + DeviceAuthPayload.buildV3( deviceId = identity.deviceId, clientId = client.id, clientMode = client.mode, @@ -584,42 +584,6 @@ class GatewaySession( } } - private fun buildDeviceAuthPayloadV3( - deviceId: String, - clientId: String, - clientMode: String, - role: String, - scopes: List, - signedAtMs: Long, - token: String?, - nonce: String, - platform: String?, - deviceFamily: String?, - ): String { - val scopeString = scopes.joinToString(",") - val authToken = token.orEmpty() - val platformNorm = normalizeDeviceMetadataField(platform) - val deviceFamilyNorm = normalizeDeviceMetadataField(deviceFamily) - val parts = - mutableListOf( - "v3", - deviceId, - clientId, - clientMode, - role, - scopeString, - signedAtMs.toString(), - authToken, - nonce, - platformNorm, - deviceFamilyNorm, - ) - return parts.joinToString("|") - } - - private fun normalizeDeviceMetadataField(value: String?): String = - value?.trim()?.lowercase(Locale.ROOT).orEmpty() - private fun normalizeCanvasHostUrl( raw: String?, endpoint: GatewayEndpoint, diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt new file mode 100644 index 00000000000..95e145fb11f --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt @@ -0,0 +1,35 @@ +package ai.openclaw.android.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeviceAuthPayloadTest { + @Test + fun buildV3_matchesCanonicalVector() { + val payload = + DeviceAuthPayload.buildV3( + deviceId = "dev-1", + clientId = "openclaw-macos", + clientMode = "ui", + role = "operator", + scopes = listOf("operator.admin", "operator.read"), + signedAtMs = 1_700_000_000_000, + token = "tok-123", + nonce = "nonce-abc", + platform = " IOS ", + deviceFamily = " iPhone ", + ) + + assertEquals( + "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone", + payload, + ) + } + + @Test + fun normalizeMetadataField_asciiOnlyLowercase() { + assertEquals("İos", DeviceAuthPayload.normalizeMetadataField(" İOS ")) + assertEquals("mac", DeviceAuthPayload.normalizeMetadataField(" MAC ")) + assertEquals("", DeviceAuthPayload.normalizeMetadataField(null)) + } +} diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index 0ed00f3565b..f75ef05fdb2 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -280,7 +280,7 @@ actor GatewayWizardClient { let connectNonce = try await self.waitForConnectChallenge() let identity = DeviceIdentityStore.loadOrCreate() let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) - let payload = buildDeviceAuthPayloadV3( + let payload = GatewayDeviceAuthPayload.buildV3( deviceId: identity.deviceId, clientId: clientId, clientMode: clientMode, @@ -327,44 +327,6 @@ actor GatewayWizardClient { } } - private func buildDeviceAuthPayloadV3( - deviceId: String, - clientId: String, - clientMode: String, - role: String, - scopes: [String], - signedAtMs: Int, - token: String?, - nonce: String, - platform: String?, - deviceFamily: String?) -> String - { - let scopeString = scopes.joined(separator: ",") - let authToken = token ?? "" - let normalizedPlatform = normalizeMetadataField(platform) - let normalizedDeviceFamily = normalizeMetadataField(deviceFamily) - return [ - "v3", - deviceId, - clientId, - clientMode, - role, - scopeString, - String(signedAtMs), - authToken, - nonce, - normalizedPlatform, - normalizedDeviceFamily, - ].joined(separator: "|") - } - - private func normalizeMetadataField(_ value: String?) -> String { - guard let value else { return "" } - return value - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(with: Locale(identifier: "en_US_POSIX")) - } - private func waitForConnectChallenge() async throws -> String { guard let task = self.task else { throw ConnectChallengeError.timeout } return try await AsyncTimeout.withTimeout( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift new file mode 100644 index 00000000000..858ef457c7e --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift @@ -0,0 +1,55 @@ +import Foundation + +public enum GatewayDeviceAuthPayload { + public static func buildV3( + deviceId: String, + clientId: String, + clientMode: String, + role: String, + scopes: [String], + signedAtMs: Int, + token: String?, + nonce: String, + platform: String?, + deviceFamily: String?) -> String + { + let scopeString = scopes.joined(separator: ",") + let authToken = token ?? "" + let normalizedPlatform = normalizeMetadataField(platform) + let normalizedDeviceFamily = normalizeMetadataField(deviceFamily) + return [ + "v3", + deviceId, + clientId, + clientMode, + role, + scopeString, + String(signedAtMs), + authToken, + nonce, + normalizedPlatform, + normalizedDeviceFamily, + ].joined(separator: "|") + } + + static func normalizeMetadataField(_ value: String?) -> String { + guard let value else { return "" } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return "" + } + // Keep cross-runtime normalization deterministic (TS/Swift/Kotlin): + // lowercase ASCII A-Z only for auth payload metadata fields. + var output = String() + output.reserveCapacity(trimmed.count) + for scalar in trimmed.unicodeScalars { + let codePoint = scalar.value + if codePoint >= 65, codePoint <= 90, let lowered = UnicodeScalar(codePoint + 32) { + output.unicodeScalars.append(lowered) + } else { + output.unicodeScalars.append(scalar) + } + } + return output + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index ab377ef91dc..e8a53412cd1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -399,7 +399,7 @@ public actor GatewayChannelActor { let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let connectNonce = try await self.waitForConnectChallenge() if includeDeviceIdentity, let identity { - let payload = buildDeviceAuthPayloadV3( + let payload = GatewayDeviceAuthPayload.buildV3( deviceId: identity.deviceId, clientId: clientId, clientMode: clientMode, @@ -443,44 +443,6 @@ public actor GatewayChannelActor { } } - private func buildDeviceAuthPayloadV3( - deviceId: String, - clientId: String, - clientMode: String, - role: String, - scopes: [String], - signedAtMs: Int, - token: String?, - nonce: String, - platform: String?, - deviceFamily: String?) -> String - { - let scopeString = scopes.joined(separator: ",") - let authToken = token ?? "" - let normalizedPlatform = normalizeMetadataField(platform) - let normalizedDeviceFamily = normalizeMetadataField(deviceFamily) - return [ - "v3", - deviceId, - clientId, - clientMode, - role, - scopeString, - String(signedAtMs), - authToken, - nonce, - normalizedPlatform, - normalizedDeviceFamily, - ].joined(separator: "|") - } - - private func normalizeMetadataField(_ value: String?) -> String { - guard let value else { return "" } - return value - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(with: Locale(identifier: "en_US_POSIX")) - } - private func handleConnectResponse( _ res: ResponseFrame, identity: DeviceIdentity?, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift new file mode 100644 index 00000000000..46a814f81a6 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import OpenClawKit + +@Suite("DeviceAuthPayload") +struct DeviceAuthPayloadTests { + @Test("builds canonical v3 payload vector") + func buildsCanonicalV3PayloadVector() { + let payload = GatewayDeviceAuthPayload.buildV3( + deviceId: "dev-1", + clientId: "openclaw-macos", + clientMode: "ui", + role: "operator", + scopes: ["operator.admin", "operator.read"], + signedAtMs: 1_700_000_000_000, + token: "tok-123", + nonce: "nonce-abc", + platform: " IOS ", + deviceFamily: " iPhone ") + #expect( + payload + == "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone") + } + + @Test("normalizes metadata with ASCII-only lowercase") + func normalizesMetadataWithAsciiLowercase() { + #expect(GatewayDeviceAuthPayload.normalizeMetadataField(" İOS ") == "İos") + #expect(GatewayDeviceAuthPayload.normalizeMetadataField(" MAC ") == "mac") + #expect(GatewayDeviceAuthPayload.normalizeMetadataField(nil) == "") + } +} diff --git a/src/gateway/device-auth.test.ts b/src/gateway/device-auth.test.ts new file mode 100644 index 00000000000..9d7ac3fb7b5 --- /dev/null +++ b/src/gateway/device-auth.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { buildDeviceAuthPayloadV3, normalizeDeviceMetadataForAuth } from "./device-auth.js"; + +describe("device-auth payload vectors", () => { + it("builds canonical v3 payload", () => { + const payload = buildDeviceAuthPayloadV3({ + deviceId: "dev-1", + clientId: "openclaw-macos", + clientMode: "ui", + role: "operator", + scopes: ["operator.admin", "operator.read"], + signedAtMs: 1_700_000_000_000, + token: "tok-123", + nonce: "nonce-abc", + platform: " IOS ", + deviceFamily: " iPhone ", + }); + + expect(payload).toBe( + "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone", + ); + }); + + it("normalizes metadata with ASCII-only lowercase", () => { + expect(normalizeDeviceMetadataForAuth(" İOS ")).toBe("İos"); + expect(normalizeDeviceMetadataForAuth(" MAC ")).toBe("mac"); + expect(normalizeDeviceMetadataForAuth(undefined)).toBe(""); + }); +}); diff --git a/src/gateway/device-auth.ts b/src/gateway/device-auth.ts index 9172da22a97..e0ef2c4eeec 100644 --- a/src/gateway/device-auth.ts +++ b/src/gateway/device-auth.ts @@ -14,11 +14,21 @@ export type DeviceAuthPayloadV3Params = DeviceAuthPayloadParams & { deviceFamily?: string | null; }; -function normalizeMetadataField(value?: string | null): string { +function toLowerAscii(input: string): string { + return input.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32)); +} + +export function normalizeDeviceMetadataForAuth(value?: string | null): string { if (typeof value !== "string") { return ""; } - return value.trim().toLowerCase(); + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + // Keep cross-runtime normalization deterministic (TS/Swift/Kotlin) by only + // lowercasing ASCII metadata fields used in auth payloads. + return toLowerAscii(trimmed); } export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { @@ -40,8 +50,8 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string export function buildDeviceAuthPayloadV3(params: DeviceAuthPayloadV3Params): string { const scopes = params.scopes.join(","); const token = params.token ?? ""; - const platform = normalizeMetadataField(params.platform); - const deviceFamily = normalizeMetadataField(params.deviceFamily); + const platform = normalizeDeviceMetadataForAuth(params.platform); + const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily); return [ "v3", params.deviceId, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 28323a2ad77..7c1b449ff4d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -32,7 +32,11 @@ import { CANVAS_CAPABILITY_TTL_MS, mintCanvasCapabilityToken, } from "../../canvas-capability.js"; -import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js"; +import { + buildDeviceAuthPayload, + buildDeviceAuthPayloadV3, + normalizeDeviceMetadataForAuth, +} from "../../device-auth.js"; import { isLocalishHost, isLoopbackAddress, @@ -131,8 +135,75 @@ function shouldAllowSilentLocalPairing(params: { ); } -function normalizeClientMetadataForComparison(value: string | undefined): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; +function resolveDeviceSignaturePayloadVersion(params: { + device: { + id: string; + signature: string; + publicKey: string; + }; + connectParams: ConnectParams; + role: string; + scopes: string[]; + signedAtMs: number; + nonce: string; +}): "v3" | "v2" | null { + const payloadV3 = buildDeviceAuthPayloadV3({ + deviceId: params.device.id, + clientId: params.connectParams.client.id, + clientMode: params.connectParams.client.mode, + role: params.role, + scopes: params.scopes, + signedAtMs: params.signedAtMs, + token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null, + nonce: params.nonce, + platform: params.connectParams.client.platform, + deviceFamily: params.connectParams.client.deviceFamily, + }); + if (verifyDeviceSignature(params.device.publicKey, payloadV3, params.device.signature)) { + return "v3"; + } + + const payloadV2 = buildDeviceAuthPayload({ + deviceId: params.device.id, + clientId: params.connectParams.client.id, + clientMode: params.connectParams.client.mode, + role: params.role, + scopes: params.scopes, + signedAtMs: params.signedAtMs, + token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null, + nonce: params.nonce, + }); + if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) { + return "v2"; + } + return null; +} + +function resolvePinnedClientMetadata(params: { + claimedPlatform?: string; + claimedDeviceFamily?: string; + pairedPlatform?: string; + pairedDeviceFamily?: string; +}): { + platformMismatch: boolean; + deviceFamilyMismatch: boolean; + pinnedPlatform?: string; + pinnedDeviceFamily?: string; +} { + const claimedPlatform = normalizeDeviceMetadataForAuth(params.claimedPlatform); + const claimedDeviceFamily = normalizeDeviceMetadataForAuth(params.claimedDeviceFamily); + const pairedPlatform = normalizeDeviceMetadataForAuth(params.pairedPlatform); + const pairedDeviceFamily = normalizeDeviceMetadataForAuth(params.pairedDeviceFamily); + const hasPinnedPlatform = pairedPlatform !== ""; + const hasPinnedDeviceFamily = pairedDeviceFamily !== ""; + const platformMismatch = hasPinnedPlatform && claimedPlatform !== pairedPlatform; + const deviceFamilyMismatch = hasPinnedDeviceFamily && claimedDeviceFamily !== pairedDeviceFamily; + return { + platformMismatch, + deviceFamilyMismatch, + pinnedPlatform: hasPinnedPlatform ? params.pairedPlatform : undefined, + pinnedDeviceFamily: hasPinnedDeviceFamily ? params.pairedDeviceFamily : undefined, + }; } export function attachGatewayWsMessageHandler(params: { @@ -588,42 +659,21 @@ export function attachGatewayWsMessageHandler(params: { rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch"); return; } - const payloadV3 = buildDeviceAuthPayloadV3({ - deviceId: device.id, - clientId: connectParams.client.id, - clientMode: connectParams.client.mode, - role, - scopes, - signedAtMs: signedAt, - token: connectParams.auth?.token ?? connectParams.auth?.deviceToken ?? null, - nonce: providedNonce, - platform: connectParams.client.platform, - deviceFamily: connectParams.client.deviceFamily, - }); - const payloadV2 = buildDeviceAuthPayload({ - deviceId: device.id, - clientId: connectParams.client.id, - clientMode: connectParams.client.mode, - role, - scopes, - signedAtMs: signedAt, - token: connectParams.auth?.token ?? connectParams.auth?.deviceToken ?? null, - nonce: providedNonce, - }); const rejectDeviceSignatureInvalid = () => rejectDeviceAuthInvalid("device-signature", "device signature invalid"); - const signatureOkV3 = verifyDeviceSignature( - device.publicKey, - payloadV3, - device.signature, - ); - const signatureOkV2 = - !signatureOkV3 && verifyDeviceSignature(device.publicKey, payloadV2, device.signature); - if (!signatureOkV3 && !signatureOkV2) { + const payloadVersion = resolveDeviceSignaturePayloadVersion({ + device, + connectParams, + role, + scopes, + signedAtMs: signedAt, + nonce: providedNonce, + }); + if (!payloadVersion) { rejectDeviceSignatureInvalid(); return; } - deviceAuthPayloadVersion = signatureOkV3 ? "v3" : "v2"; + deviceAuthPayloadVersion = payloadVersion; devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey); if (!devicePublicKey) { rejectDeviceAuthInvalid("device-public-key", "device public key invalid"); @@ -784,17 +834,13 @@ export function attachGatewayWsMessageHandler(params: { const pairedPlatform = paired.platform; const claimedDeviceFamily = connectParams.client.deviceFamily; const pairedDeviceFamily = paired.deviceFamily; - const hasPinnedPlatform = normalizeClientMetadataForComparison(pairedPlatform) !== ""; - const hasPinnedDeviceFamily = - normalizeClientMetadataForComparison(pairedDeviceFamily) !== ""; - const platformMismatch = - hasPinnedPlatform && - normalizeClientMetadataForComparison(claimedPlatform) !== - normalizeClientMetadataForComparison(pairedPlatform); - const deviceFamilyMismatch = - hasPinnedDeviceFamily && - normalizeClientMetadataForComparison(claimedDeviceFamily) !== - normalizeClientMetadataForComparison(pairedDeviceFamily); + const metadataPinning = resolvePinnedClientMetadata({ + claimedPlatform, + claimedDeviceFamily, + pairedPlatform, + pairedDeviceFamily, + }); + const { platformMismatch, deviceFamilyMismatch } = metadataPinning; if (platformMismatch || deviceFamilyMismatch) { logGateway.warn( `security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? ""} pinnedPlatform=${pairedPlatform ?? ""} claimedDeviceFamily=${claimedDeviceFamily ?? ""} pinnedDeviceFamily=${pairedDeviceFamily ?? ""} client=${connectParams.client.id} conn=${connId}`, @@ -804,11 +850,11 @@ export function attachGatewayWsMessageHandler(params: { return; } } else { - if (hasPinnedPlatform && pairedPlatform) { - connectParams.client.platform = pairedPlatform; + if (metadataPinning.pinnedPlatform) { + connectParams.client.platform = metadataPinning.pinnedPlatform; } - if (hasPinnedDeviceFamily) { - connectParams.client.deviceFamily = pairedDeviceFamily; + if (metadataPinning.pinnedDeviceFamily) { + connectParams.client.deviceFamily = metadataPinning.pinnedDeviceFamily; } } const pairedRoles = Array.isArray(paired.roles)