mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
* fix(relay): refuse silent fallback when pairing invite fails When Orca Relay pairing fails, don't silently degrade to a LAN-only QR under the Relay label. Instead, surface structured failure information so the UI can clearly inform the user and offer recovery options. * fix issues
211 lines
7.9 KiB
TypeScript
211 lines
7.9 KiB
TypeScript
// Why: per-device tokens replace the shared runtime auth token for WebSocket
|
|
// (mobile) connections. Each paired device gets its own revocable token so
|
|
// compromising one device doesn't expose others. The registry is a simple
|
|
// JSON file with hardened permissions matching the runtime metadata pattern.
|
|
import { randomBytes, randomUUID } from 'node:crypto'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
|
|
import type { DeviceScope } from '../../shared/runtime-types'
|
|
import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files'
|
|
import type { RelayDeviceBinding } from './relay/relay-revoke-outbox'
|
|
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
|
|
|
|
export type { DeviceScope }
|
|
|
|
export type DeviceEntry = {
|
|
deviceId: string
|
|
name: string
|
|
token: string
|
|
scope: DeviceScope
|
|
pairedAt: number
|
|
lastSeenAt: number
|
|
relayBinding?: RelayDeviceBinding
|
|
mobilePairingConnectionMode?: MobilePairingConnectionMode
|
|
}
|
|
|
|
function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined {
|
|
if (!value || typeof value !== 'object') {
|
|
return undefined
|
|
}
|
|
const binding = value as Partial<RelayDeviceBinding>
|
|
return binding.relayDeviceId === deviceId &&
|
|
typeof binding.relayHostId === 'string' &&
|
|
typeof binding.ownerIdentityKey === 'string'
|
|
? {
|
|
relayHostId: binding.relayHostId,
|
|
relayDeviceId: binding.relayDeviceId,
|
|
ownerIdentityKey: binding.ownerIdentityKey,
|
|
...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt)
|
|
? { inviteExpiresAt: binding.inviteExpiresAt }
|
|
: {})
|
|
}
|
|
: undefined
|
|
}
|
|
|
|
export class DeviceRegistry {
|
|
private readonly registryPath: string
|
|
private devices: DeviceEntry[] = []
|
|
|
|
constructor(userDataPath: string) {
|
|
this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME)
|
|
this.load()
|
|
}
|
|
|
|
addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
|
|
return this.createAndPersistDevice(this.devices, name, scope)
|
|
}
|
|
|
|
private createAndPersistDevice(
|
|
existingDevices: DeviceEntry[],
|
|
name: string,
|
|
scope: DeviceScope
|
|
): DeviceEntry {
|
|
const entry: DeviceEntry = {
|
|
deviceId: randomUUID(),
|
|
name,
|
|
token: randomBytes(24).toString('hex'),
|
|
scope,
|
|
pairedAt: Date.now(),
|
|
lastSeenAt: 0
|
|
}
|
|
const nextDevices = [...existingDevices, entry]
|
|
// Why: a credential is not valid until its durable registry write succeeds.
|
|
this.save(nextDevices)
|
|
this.devices = nextDevices
|
|
return entry
|
|
}
|
|
|
|
// Why: coalesce repeated QR-regenerate clicks onto a single pending token.
|
|
// Each call to addDevice() produces a valid auth credential; without
|
|
// coalescing, every renderer call to mobile:getPairingQR (e.g. the new
|
|
// copy-button flow that encourages regeneration) leaves an orphaned token
|
|
// forever. Returns an existing never-scanned entry if present; otherwise
|
|
// mints a new one and drops any stale pending entries.
|
|
getOrCreatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
|
|
const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope)
|
|
if (existing) {
|
|
return existing
|
|
}
|
|
return this.addDevice(name, scope)
|
|
}
|
|
|
|
// Why: explicit rotation path for "Regenerate QR" — invalidates any
|
|
// existing never-scanned token (e.g. one that was screenshotted, copied
|
|
// to clipboard, or shown on a screen-share) and mints a fresh one. Without
|
|
// this, getOrCreatePendingDevice keeps returning the same token forever
|
|
// until a phone actually pairs, so users have no way to revoke a leaked
|
|
// pre-pairing token.
|
|
rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
|
|
const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope)
|
|
return this.createAndPersistDevice(retainedDevices, name, scope)
|
|
}
|
|
|
|
removeDevice(deviceId: string): boolean {
|
|
const nextDevices = this.devices.filter((d) => d.deviceId !== deviceId)
|
|
if (nextDevices.length === this.devices.length) {
|
|
return false
|
|
}
|
|
// Why: persist before memory swap so a failed write does not drop a device
|
|
// only in-process while disk still lists it (and vice versa on reload).
|
|
this.save(nextDevices)
|
|
this.devices = nextDevices
|
|
return true
|
|
}
|
|
|
|
getDevice(deviceId: string): DeviceEntry | null {
|
|
return this.devices.find((d) => d.deviceId === deviceId) ?? null
|
|
}
|
|
|
|
getPendingDevice(scope: DeviceScope = 'mobile'): DeviceEntry | null {
|
|
return this.devices.find((device) => device.lastSeenAt === 0 && device.scope === scope) ?? null
|
|
}
|
|
|
|
setRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean {
|
|
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
|
|
if (index < 0 || binding.relayDeviceId !== deviceId) {
|
|
return false
|
|
}
|
|
const nextDevices = this.devices.map((device, candidateIndex) =>
|
|
candidateIndex === index ? { ...device, relayBinding: binding } : device
|
|
)
|
|
this.save(nextDevices)
|
|
this.devices = nextDevices
|
|
return true
|
|
}
|
|
|
|
setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean {
|
|
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
|
|
if (index < 0 || this.devices[index]?.scope !== 'mobile') {
|
|
return false
|
|
}
|
|
// Why: persist before swapping memory so a failed write does not leave a
|
|
// mode the UI/runtime believe was stored.
|
|
const nextDevices = this.devices.map((device, candidateIndex) =>
|
|
candidateIndex === index ? { ...device, mobilePairingConnectionMode: mode } : device
|
|
)
|
|
this.save(nextDevices)
|
|
this.devices = nextDevices
|
|
return true
|
|
}
|
|
|
|
getMobilePairingConnectionMode(deviceId: string): MobilePairingConnectionMode | null {
|
|
const device = this.devices.find((candidate) => candidate.deviceId === deviceId)
|
|
if (!device || device.scope !== 'mobile') {
|
|
return null
|
|
}
|
|
// Why: pairings created before this preference existed used automatic
|
|
// direct-first Relay fallback, so missing state must preserve that behavior.
|
|
return device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic'
|
|
}
|
|
|
|
listDevices(): readonly DeviceEntry[] {
|
|
return this.devices
|
|
}
|
|
|
|
validateToken(token: string): DeviceEntry | null {
|
|
return this.devices.find((d) => d.token === token) ?? null
|
|
}
|
|
|
|
updateLastSeen(deviceId: string): void {
|
|
const index = this.devices.findIndex((d) => d.deviceId === deviceId)
|
|
if (index < 0) {
|
|
return
|
|
}
|
|
// Why: persist before memory swap so a failed write cannot leave a scanned
|
|
// device looking never-scanned on disk, where rotation would drop it.
|
|
const seenAt = Date.now()
|
|
const nextDevices = this.devices.map((device, candidateIndex) =>
|
|
candidateIndex === index ? { ...device, lastSeenAt: seenAt } : device
|
|
)
|
|
this.save(nextDevices)
|
|
this.devices = nextDevices
|
|
}
|
|
|
|
private load(): void {
|
|
if (!existsSync(this.registryPath)) {
|
|
this.devices = []
|
|
return
|
|
}
|
|
try {
|
|
hardenExistingSecureFile(this.registryPath)
|
|
const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
|
|
this.devices = parsed.map((device) => ({
|
|
...device,
|
|
// Why: older registries only existed for phone pairing. Treat missing
|
|
// scope as mobile so legacy device tokens do not gain new CLI powers.
|
|
scope: device.scope === 'runtime' ? 'runtime' : 'mobile',
|
|
relayBinding: validRelayBinding(device.relayBinding, device.deviceId),
|
|
mobilePairingConnectionMode:
|
|
device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic'
|
|
}))
|
|
} catch {
|
|
this.devices = []
|
|
}
|
|
}
|
|
|
|
private save(devices: DeviceEntry[]): void {
|
|
writeSecureJsonFile(this.registryPath, devices)
|
|
}
|
|
}
|