// Paired-device registry — v3 sync. // // Long-term records live in `chrome.storage.local`. Keys must not leave // the device via a third-party sync service — `longTermPublicKeyHex` and // `signingPublicKeyHex` are cryptographic keys; routing them through // Google's or Mozilla's sync back-end violates the local-only trust model. // // `deviceId` is the canonical primary key (UUID v4, stable for the // lifetime of the installation). Its cryptographic identity keys are // immutable once persisted; a direct re-pair must use a fresh deviceId. // // The registry has only ever lived in `chrome.storage.local` for released // builds, so no cross-profile evacuation path is needed — the public keys // never leave the device via a third-party sync back-end. Unpairing is // local-only; Noise KK rejection on the next connection is the security // boundary (see removePairedDeviceById). import type { DeviceRevocation, PairedDevice } from "../shared/sync-types"; import { constantTimeEqualHex } from "../shared/constant-time"; import { capDeviceName, clampSyncTimestamp } from "../shared/sync-limits"; import type { SyncOutcome } from "../shared/sync-outcome"; const REGISTRY_KEY = "peach_paired_devices_v3"; const REVOCATIONS_KEY = "peach_device_revocations_v1"; /** Bound the list so a hostile peer cannot grow storage without limit. */ const MAX_REVOCATIONS = 500; const LAST_SYNC_AT_KEY = "peach_last_sync_at"; const LAST_SYNC_RESULT_KEY = "peach_last_sync_result"; let deviceMutationTail: Promise = Promise.resolve(); function enqueueDeviceMutation(mutation: () => Promise): Promise { const result = deviceMutationTail.then(mutation, mutation); deviceMutationTail = result.then(() => undefined, () => undefined); return result; } async function listPairedDevicesRaw(): Promise { try { const result = await chrome.storage.local.get(REGISTRY_KEY); const devices = (result as Record)[REGISTRY_KEY]; if (Array.isArray(devices)) return devices.filter(isPairedDevice); return []; } catch { return []; } } export async function listPairedDevices(): Promise { await deviceMutationTail; return listPairedDevicesRaw(); } export async function findPairedDeviceById(deviceId: string): Promise { const devices = await listPairedDevices(); return devices.find((d) => d.deviceId === deviceId) ?? null; } export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise { const devices = await listPairedDevices(); return devices.find((d) => d.longTermPublicKeyHex !== undefined && constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex) ) ?? null; } /** * Durable registry write. PB-26-213: the reusable rendezvous secret must * never reach this registry — it lives in the encrypted pair-secret record * (see pair-secrets.ts). Any secret on an inbound row is refused here, so a * missed writer fails closed to a secretless row instead of plaintext disk. */ export async function writePairedDevices(devices: PairedDevice[]): Promise { await enqueueDeviceMutation(() => writePairedDevicesRaw(devices)); } async function writePairedDevicesRaw(devices: PairedDevice[]): Promise { await chrome.storage.local.set({ [REGISTRY_KEY]: devices.map((device) => { const normalized = withSigningKeyHistory(device); if (!isValidSecret(normalized.rendezvousSecretHex)) return normalized; const scrubbed = { ...normalized }; delete scrubbed.rendezvousSecretHex; return scrubbed; }), }); } function isValidSecret(value: unknown): value is string { return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); } /** * Add or update a device by `deviceId`. Existing cryptographic identity keys * are immutable; metadata-only updates merge into the stored row. */ export async function upsertPairedDevice(device: PairedDevice): Promise { if (!device.deviceId) throw new Error("device.deviceId is required"); if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required"); if (!device.deviceName) throw new Error("device.deviceName is required"); if (!device.deviceType) throw new Error("device.deviceType is required"); await upsertPairedDevices([device]); } export async function upsertPairedDevices(devices: PairedDevice[]): Promise { await enqueueDeviceMutation(async () => { const stored = await listPairedDevicesRaw(); const incomingById = new Map(); for (const device of devices) { if (!device.deviceId) throw new Error("device.deviceId is required"); if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required"); if (!device.deviceName) throw new Error("device.deviceName is required"); if (!device.deviceType) throw new Error("device.deviceType is required"); const normalized = { ...device, deviceName: capDeviceName(device.deviceName) }; const previous = incomingById.get(normalized.deviceId); if (previous) { assertIdentityBinding(previous, normalized); throw new Error("device_identity_duplicate"); } incomingById.set(normalized.deviceId, normalized); const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId); if (existing) assertIdentityBinding(existing, normalized); } for (const device of incomingById.values()) { const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId); if (index >= 0) { const existing = stored[index]; const merged = { ...existing, ...device }; if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) { merged.signingPublicKeyHex = existing.signingPublicKeyHex; } stored[index] = merged; } else { stored.push(device); } } await writePairedDevicesRaw(stored); }); } /** Validate identity bindings without changing the registry. */ export async function validatePairedDeviceIdentities(devices: PairedDevice[]): Promise { await enqueueDeviceMutation(async () => { const stored = await listPairedDevicesRaw(); const incomingById = new Map(); for (const device of devices) { const previous = incomingById.get(device.deviceId); if (previous) { assertIdentityBinding(previous, device); throw new Error("device_identity_duplicate"); } incomingById.set(device.deviceId, device); const existing = stored.find((candidate) => candidate.deviceId === device.deviceId); if (existing) assertIdentityBinding(existing, device); } }); } function validateAndIndexDevices( stored: PairedDevice[], devices: PairedDevice[], ): Map { const incomingById = new Map(); for (const device of devices) { if (!device.deviceId) throw new Error("device.deviceId is required"); if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required"); if (!device.deviceName) throw new Error("device.deviceName is required"); if (!device.deviceType) throw new Error("device.deviceType is required"); const normalized = { ...device, deviceName: capDeviceName(device.deviceName) }; const previous = incomingById.get(normalized.deviceId); if (previous) { assertIdentityBinding(previous, normalized); throw new Error("device_identity_duplicate"); } incomingById.set(normalized.deviceId, normalized); const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId); if (existing) assertIdentityBinding(existing, normalized); } return incomingById; } function assertIdentityBinding(existing: PairedDevice, incoming: PairedDevice): void { if ( typeof existing.longTermPublicKeyHex === "string" && typeof incoming.longTermPublicKeyHex === "string" && !constantTimeEqualHex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex) ) { throw new Error("device_identity_immutable"); } if ( typeof existing.signingPublicKeyHex === "string" && typeof incoming.signingPublicKeyHex === "string" && !constantTimeEqualHex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex) ) { throw new Error("device_identity_immutable"); } } function withSigningKeyHistory(device: PairedDevice): PairedDevice { if (!device.signingPublicKeyHex || !device.signingKeyHistory?.length) return device; const current = device.signingPublicKeyHex.toLowerCase(); const history = device.signingKeyHistory .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex)) .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() })); return { ...device, signingKeyHistory: [...history.filter((key) => key.publicKeyHex !== current), { version: history.find((key) => key.publicKeyHex === current)?.version ?? history.reduce((max, key) => Math.max(max, key.version), 0) + 1, publicKeyHex: current }] }; } export async function renamePairedDevice(deviceId: string, name: string): Promise { const alias = name.trim(); if (!alias) throw new Error("device name must not be empty"); if (alias.length > 64) throw new Error("device name must be 64 chars or fewer"); await enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); const index = devices.findIndex((device) => device.deviceId === deviceId); if (index < 0) throw new Error("device_not_found"); devices[index] = { ...devices[index], localAlias: alias }; await chrome.storage.local.set({ [REGISTRY_KEY]: devices }); }); } /** * Move every existing pair to the user-selected mesh relay. * * Reconnect prefers the relay stored on each pair over the global fallback, so * changing only the setting would leave established devices on the old relay. */ export async function setRelayUrlForAllPairedDevices( relayUrl: string, ): Promise { await enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); if (devices.length === 0) return; await chrome.storage.local.set({ [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })), }); }); // A relay change is mesh state — the next alarm tick or popup open will dial // with the new relay. No fire-and-forget trigger here; the in-flight guard // would just drop it on the burst of upserts that often follows. } /** * Removes the device from the LOCAL paired-devices registry only, leaving no * record behind. Prefer `revokeDeviceById` for anything the user would call * "remove this device" — a plain removal is silently undone the next time any * peer vouches the device back through a manifest. * * SECURITY NOTE (MV-8): this does NOT prevent the removed device from * syncing with OTHER peers in the device graph, and the device RETAINS * its copy of the vault root key (syncMasterKey) — it can still decrypt * anything it already holds and anything other peers send it. For a * lost or stolen device, removal is insufficient: use "Rotate sync * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so * the old device's key material is useless. */ export async function removePairedDeviceById(deviceId: string): Promise { await enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); await chrome.storage.local.set({ [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId), }); }); // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE // handler — see upsertPairedDevice for why this lives there, not here. } // --------------------------------------------------------------------------- // Device revocation (Part 5 §C) — a removal that propagates and stays removed. // --------------------------------------------------------------------------- async function listDeviceRevocationsRaw(): Promise { try { const result = await chrome.storage.local.get(REVOCATIONS_KEY); const value = (result as Record)[REVOCATIONS_KEY]; if (Array.isArray(value)) return value.filter(isDeviceRevocation); return []; } catch { return []; } } export async function listDeviceRevocations(): Promise { await deviceMutationTail; return listDeviceRevocationsRaw(); } /** * True when either the id or the long-term key is under a terminal revocation. * * `deviceId` is a self-asserted string in a manifest, so keying terminality on * it alone is bypassed by re-vouching a revoked device's key under a fresh id. * The key is the thing a revoked device actually still holds, so it is the * thing terminality has to follow. */ export async function isDeviceRevoked( deviceId: string, longTermPublicKeyHex?: string, ): Promise { // The all-zero sentinel stands for "we never stored a key for this device" // (see revokeDeviceById) and must not match a real one. const key = longTermPublicKeyHex?.toLowerCase(); const keyIsRevocable = key !== undefined && key !== "0".repeat(64); await deviceMutationTail; return (await listDeviceRevocationsRaw()).some( (r) => r.deviceId === deviceId || (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key), ); } /** * Revoke a device: record it locally, then drop it from the registry. The * record rides out in every manifest this device signs from now on, and blocks * the device being vouched back in. A stale entry may survive in the * pending-vouch queue; the confirm handler re-checks revocation before * promoting anything out of it. */ export async function revokeDeviceById( deviceId: string, revokedByDeviceId: string, options: { now?: number } = {}, ): Promise { return enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); const device = devices.find((candidate) => candidate.deviceId === deviceId); const existing = await listDeviceRevocationsRaw(); if (existing.some((r) => r.deviceId === deviceId)) { // A direct re-pair in older builds could restore the paired row without // clearing its terminal revocation. "Remove" then returned early here and // left the row visible forever. Idempotency applies to the revocation // record, not to removing any stale live row that overlaps it. if (device) await chrome.storage.local.set({ [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId), }); return null; } const record: DeviceRevocation = { deviceId, // A device with no stored key was never usable as a sync peer; the // all-zero key keeps the record well-formed on the wire without // pretending to name a key we never saw. longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64), revokedAt: options.now ?? Date.now(), revokedByDeviceId, }; await chrome.storage.local.set({ [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId), [REVOCATIONS_KEY]: capRevocations([...existing, record]), }); return record; }); } /** * Clear a revocation after an authenticated, SAS-confirmed direct re-pair. * A changed signing key is accepted only through this seam and the old key * remains in immutable history. */ export async function upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise { if (!device.deviceId || !device.longTermPublicKeyHex || !device.deviceName || !device.deviceType) { throw new Error("invalid paired device"); } await enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); const stored = devices.find((candidate) => candidate.deviceId === device.deviceId); const incoming = { ...device, deviceName: capDeviceName(device.deviceName!) }; if (stored) { if (stored.longTermPublicKeyHex && incoming.longTermPublicKeyHex && !constantTimeEqualHex(stored.longTermPublicKeyHex, incoming.longTermPublicKeyHex)) { throw new Error("device_identity_immutable"); } if (stored.signingPublicKeyHex && incoming.signingPublicKeyHex && !constantTimeEqualHex(stored.signingPublicKeyHex, incoming.signingPublicKeyHex)) { incoming.signingKeyHistory = [ ...(stored.signingKeyHistory ?? []), { version: (stored.signingKeyHistory?.reduce((m, k) => Math.max(m, k.version), 0) ?? 0) + 1, publicKeyHex: stored.signingPublicKeyHex.toLowerCase() }, ]; } } const index = devices.findIndex((candidate) => candidate.deviceId === device.deviceId); if (index >= 0) { const merged = { ...devices[index], ...incoming }; if (typeof devices[index].signingPublicKeyHex === "string" && incoming.signingPublicKeyHex == null) { merged.signingPublicKeyHex = devices[index].signingPublicKeyHex; } devices[index] = merged; } else { devices.push(incoming); } const revocations = await listDeviceRevocationsRaw(); const filtered = revocations.filter((record) => record.deviceId !== device.deviceId); await chrome.storage.local.set({ [REGISTRY_KEY]: scrubRegistryRows(devices), [REVOCATIONS_KEY]: capRevocations(filtered), }); }); } export async function upsertPairedDevicesAndMergeRevocations( devices: PairedDevice[], incomingRevocations: DeviceRevocation[], ): Promise { return enqueueDeviceMutation(async () => { const stored = await listPairedDevicesRaw(); const incomingById = validateAndIndexDevices(stored, devices); const existingRevocations = await listDeviceRevocationsRaw(); const knownById = new Set(existingRevocations.map((record) => record.deviceId)); const fresh = incomingRevocations .filter((record) => !knownById.has(record.deviceId)) .map((record) => ({ ...record, revokedAt: clampSyncTimestamp(record.revokedAt) })); const allRevocations = [...existingRevocations, ...fresh]; const revokedIds = new Set(allRevocations.map((record) => record.deviceId)); for (const device of incomingById.values()) { const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId); if (index >= 0) { const existing = stored[index]; const merged = { ...existing, ...device }; if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) { merged.signingPublicKeyHex = existing.signingPublicKeyHex; } stored[index] = merged; } else { stored.push(device); } } const revokedKeys = new Set( allRevocations .map((record) => record.longTermPublicKeyHex.toLowerCase()) .filter((key) => key !== "0".repeat(64)), ); const registry = scrubRegistryRows( stored.filter( (device) => !revokedIds.has(device.deviceId) && !revokedKeys.has(device.longTermPublicKeyHex?.toLowerCase() ?? ""), ), ); await chrome.storage.local.set({ [REGISTRY_KEY]: registry, [REVOCATIONS_KEY]: capRevocations(allRevocations), }); return fresh.map((record) => record.deviceId); }); } function scrubRegistryRows(devices: PairedDevice[]): PairedDevice[] { return devices.map((entry) => { if (!isValidSecret(entry.rendezvousSecretHex)) return entry; const scrubbed = { ...entry }; delete scrubbed.rendezvousSecretHex; return scrubbed; }); } /** * True when the incoming identity matches an existing paired device's deviceId * OR a terminal revocation record (by deviceId or long-term public key). * A fresh pairing ceremony must not reuse terminally revoked identity material. */ export async function hasAdoptionIdentityConflict( deviceId: string, longTermPublicKeyHex: string, ): Promise { await deviceMutationTail; const devices = await listPairedDevicesRaw(); if (devices.some((d) => d.deviceId === deviceId)) return true; const key = longTermPublicKeyHex.toLowerCase(); return (await listDeviceRevocationsRaw()).some((record) => record.deviceId === deviceId || (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key) ); } /** * Merge revocations learned from a peer's manifest. Returns the deviceIds that * were newly revoked, so the caller can evict them. * * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is * terminal, so the timestamp is display metadata only and cannot be used to * win or lose a race (HOLE 2). */ export async function mergeDeviceRevocations( incoming: DeviceRevocation[], ): Promise { return enqueueDeviceMutation(async () => { const existing = await listDeviceRevocationsRaw(); const known = new Set(existing.map((r) => r.deviceId)); const fresh = incoming .filter((r) => !known.has(r.deviceId)) // Clamped for display sanity only — a year-9999 revokedAt would render as // nonsense. The protection against ordering games is that revocation is // terminal, not this clamp (HOLE 2 audit in sync-limits.ts). .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) })); if (fresh.length === 0) return []; await storeRevocationsRaw([...existing, ...fresh]); return fresh.map((r) => r.deviceId); }); } /** * Repair the invalid paired+revoked overlap produced by older direct-pair * handlers. * * A newer direct, SAS-confirmed pair is the only state allowed to lift the * older revocation. Every other overlap resolves in favour of the revocation, * including vouched devices and pairs that predate the removal. */ export async function repairPairedRevocationOverlaps(): Promise<{ revocationsCleared: number; pairedRowsRemoved: number; }> { return enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); const revocations = await listDeviceRevocationsRaw(); const revocationById = new Map(revocations.map((record) => [record.deviceId, record])); const revocationIdsToClear = new Set(); const pairedIdsToRemove = new Set(); for (const device of devices) { const revocation = revocationById.get(device.deviceId); if (!revocation) continue; if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) { revocationIdsToClear.add(device.deviceId); } else { pairedIdsToRemove.add(device.deviceId); } } if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) { return { revocationsCleared: 0, pairedRowsRemoved: 0 }; } await chrome.storage.local.set({ [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)), [REVOCATIONS_KEY]: revocations.filter( (record) => !revocationIdsToClear.has(record.deviceId), ), }); return { revocationsCleared: revocationIdsToClear.size, pairedRowsRemoved: pairedIdsToRemove.size, }; }); } async function storeRevocationsRaw(records: DeviceRevocation[]): Promise { await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) }); } /** * PB-26-110: bound the ledger by evicting from whichever issuer currently holds * the most records, oldest first. * * Evicting globally-oldest-first let one early flood consume the whole cap and * permanently starve every other issuer, including genuine revocations, because * declining to evict at all is not a bounded option. Largest-holder-first keeps * the bound while guaranteeing a flood can only ever displace its own records: * once an issuer holds more than anyone else, everything it adds costs it one of * its own oldest. * * ponytail: O(evictions x issuers) rescan of the per-issuer lists. Fine at a * 500-record cap; switch to a live-count map if the cap ever grows by orders of * magnitude. */ function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] { if (records.length <= MAX_REVOCATIONS) return records; const byIssuer = new Map(); for (const record of records) { const key = record.revokedByDeviceId ?? ""; const list = byIssuer.get(key); if (list) list.push(record); else byIssuer.set(key, [record]); } const dropped = new Set(); for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) { let victim: DeviceRevocation[] | undefined; for (const list of byIssuer.values()) { const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0); if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) { victim = list; } } if (!victim) break; const oldest = victim.find((r) => !dropped.has(r)); if (!oldest) break; dropped.add(oldest); } return records.filter((r) => !dropped.has(r)); } function isDeviceRevocation(x: unknown): x is DeviceRevocation { if (typeof x !== "object" || x === null) return false; const r = x as Record; return ( typeof r.deviceId === "string" && !!r.deviceId && typeof r.longTermPublicKeyHex === "string" && typeof r.revokedAt === "number" && typeof r.revokedByDeviceId === "string" ); } export async function clearPairedDevices(): Promise { await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY)); } export async function markLastSyncedAt(deviceId: string, when: number): Promise { await enqueueDeviceMutation(async () => { const devices = await listPairedDevicesRaw(); const idx = devices.findIndex((d) => d.deviceId === deviceId); if (idx < 0) return; devices[idx] = { ...devices[idx], lastSyncedAt: when }; await chrome.storage.local.set({ [REGISTRY_KEY]: devices }); }); } export async function getLastSyncAt(): Promise { try { const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY); const value = (result as Record)[LAST_SYNC_AT_KEY]; if (typeof value === "number") return value; if (typeof value === "string" && value !== "") { const n = Number.parseInt(value, 10); return Number.isFinite(n) ? n : null; } return null; } catch { return null; } } export async function setLastSyncAt(when: number): Promise { await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when }); } /** * The last sync ATTEMPT, not just the last success. A 3-second flash in the * popup is useless for debugging a real phone, so every terminating path * writes this and Settings reads it back after the fact — including across an * MV3 service-worker restart. */ export interface SyncResult { outcome: SyncOutcome; detail?: string; /** When the attempt finished, success or not. */ at: number; entriesReceived: number; entriesSent: number; } export async function getLastSyncResult(): Promise { try { const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY); const value = (result as Record)[LAST_SYNC_RESULT_KEY]; if ( value && typeof value === "object" && typeof (value as Record).outcome === "string" && typeof (value as Record).at === "number" && typeof (value as Record).entriesReceived === "number" && typeof (value as Record).entriesSent === "number" ) { return value as SyncResult; } return null; } catch { return null; } } export async function setLastSyncResult(result: SyncResult): Promise { await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result }); } function isPairedDevice(x: unknown): x is PairedDevice { if (typeof x !== "object" || x === null) return false; const r = x as Record; if (typeof r.deviceId !== "string" || !r.deviceId) return false; if (typeof r.pairedAt !== "number") return false; if ( r.localAlias !== undefined && (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64) ) { return false; } if ( r.rendezvousSecretHex !== undefined && (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex)) ) { return false; } if (r.relayUrl !== undefined) { if (typeof r.relayUrl !== "string") return false; try { if (new URL(r.relayUrl).protocol !== "wss:") return false; } catch { return false; } } return true; }