mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
feat(relay): gate desktop controls on mobile demand
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -107,6 +107,7 @@ describe('recovering pairing relay candidate', () => {
|
||||
['wrong cell', new RelayOuterError(4409)],
|
||||
['planned drain', new RelayOuterError(4503)],
|
||||
['opaque close', new RelayOuterError(1006)],
|
||||
['HTTP 502', new Error('HTTP 502')],
|
||||
['HTTP 503', new Error('HTTP 503')],
|
||||
['HTTP 504', new Error('HTTP 504')],
|
||||
['transport failure', new Error('relay transport error')]
|
||||
|
||||
@@ -2209,6 +2209,7 @@ app.whenReady().then(async () => {
|
||||
runtimeRpc.setMobileRelayPairingProvider({
|
||||
createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId),
|
||||
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
|
||||
onDemandStateChanged: () => relayService.demandStateChanged(),
|
||||
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
|
||||
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
|
||||
})
|
||||
|
||||
@@ -33,7 +33,10 @@ function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding
|
||||
? {
|
||||
relayHostId: binding.relayHostId,
|
||||
relayDeviceId: binding.relayDeviceId,
|
||||
ownerIdentityKey: binding.ownerIdentityKey
|
||||
ownerIdentityKey: binding.ownerIdentityKey,
|
||||
...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt)
|
||||
? { inviteExpiresAt: binding.inviteExpiresAt }
|
||||
: {})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
RelayRevokeOutboxItem
|
||||
} from './relay-revoke-outbox'
|
||||
import type { DeviceCredentialInstallAuthorization } from './relay-control-requests'
|
||||
import { deriveRelayHostId } from './relay-http-client'
|
||||
import { RelayDemandLedger } from './relay-demand-ledger'
|
||||
|
||||
type DesktopRelayServiceOptions = {
|
||||
authConfig: OrcaCloudAuthConfig
|
||||
@@ -44,6 +46,9 @@ export class DesktopRelayService {
|
||||
private readonly coordinator: RelayAuthCoordinator
|
||||
private readonly revokeOutbox: RelayRevokeOutbox
|
||||
private readonly runtimeRpc: OrcaRuntimeRpcServer
|
||||
private readonly demandLedger: RelayDemandLedger
|
||||
private demandExpiryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private stopped = false
|
||||
|
||||
constructor(options: DesktopRelayServiceOptions) {
|
||||
const keypair = options.runtimeRpc.getE2EEKeypair()
|
||||
@@ -53,8 +58,17 @@ export class DesktopRelayService {
|
||||
}
|
||||
this.runtimeRpc = options.runtimeRpc
|
||||
this.revokeOutbox = options.runtimeRpc.getRelayRevokeOutbox()
|
||||
this.demandLedger = new RelayDemandLedger({
|
||||
deviceRegistry: options.runtimeRpc.getDeviceRegistry()!,
|
||||
revokeOutbox: this.revokeOutbox,
|
||||
relayHostId: deriveRelayHostId(keypair.publicKey)
|
||||
})
|
||||
this.coordinator = new RelayAuthCoordinator({
|
||||
readContext: () => readRelayAuthContext(options.authConfig, options.userDataPath),
|
||||
hasDemand: ({ identity }) =>
|
||||
this.demandLedger.hasDemand(
|
||||
`${identity.userId}\0${identity.profileId}\0${identity.organizationId}`
|
||||
),
|
||||
openBroker: async ({ context, isCurrent, refreshAccessToken }) => {
|
||||
const broker = await RelaySessionBroker.connect({
|
||||
authConfig: options.authConfig,
|
||||
@@ -75,11 +89,11 @@ export class DesktopRelayService {
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.coordinator.reconcile()
|
||||
this.refreshDemand()
|
||||
}
|
||||
|
||||
authMutated(): void {
|
||||
this.coordinator.reconcile()
|
||||
this.refreshDemand()
|
||||
}
|
||||
|
||||
fenceAndCloseNow(): void {
|
||||
@@ -89,21 +103,23 @@ export class DesktopRelayService {
|
||||
async createPairingRelay(
|
||||
relayDeviceId: string
|
||||
): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> {
|
||||
const broker = this.coordinator.getActiveBroker()
|
||||
if (!(broker instanceof RelaySessionBroker)) {
|
||||
throw new Error('relay_control_not_active')
|
||||
}
|
||||
return {
|
||||
relay: await broker.createPairingRelay(relayDeviceId),
|
||||
binding: {
|
||||
relayHostId: broker.hostId,
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: broker.ownerIdentityKey
|
||||
return await this.withTransientDemand(`pairing:${relayDeviceId}`, async () => {
|
||||
const broker = await this.requireActiveBroker()
|
||||
const relay = await broker.createPairingRelay(relayDeviceId)
|
||||
return {
|
||||
relay,
|
||||
binding: {
|
||||
relayHostId: broker.hostId,
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: broker.ownerIdentityKey,
|
||||
inviteExpiresAt: relay.inviteExpiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void {
|
||||
this.refreshDemand()
|
||||
const broker = this.coordinator.getActiveBroker()
|
||||
if (
|
||||
broker instanceof RelaySessionBroker &&
|
||||
@@ -119,31 +135,33 @@ export class DesktopRelayService {
|
||||
params: PairingGetEndpointsParams
|
||||
): Promise<PairingGetEndpointsResult> {
|
||||
this.requireMobileDevice(context.deviceId)
|
||||
const broker = this.coordinator.getActiveBroker()
|
||||
if (!(broker instanceof RelaySessionBroker) || !broker.endpoint) {
|
||||
return { v: 1, relay: null }
|
||||
}
|
||||
this.assertRelayHost(context, broker)
|
||||
const result: PairingGetEndpointsResult = { v: 1, relay: broker.endpoint }
|
||||
if (params.installReqId) {
|
||||
result.installStatus = await broker.credentialInstallStatus(
|
||||
context.deviceId,
|
||||
params.installReqId
|
||||
)
|
||||
}
|
||||
if (params.resumeConfirmReqId) {
|
||||
if (
|
||||
context.transport.transport !== 'relay' ||
|
||||
context.transport.credentialKind !== 'resume'
|
||||
) {
|
||||
throw new Error('resume_confirmation_unavailable')
|
||||
return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => {
|
||||
const broker = await this.activeBrokerForDemand()
|
||||
if (!broker?.endpoint) {
|
||||
return { v: 1, relay: null }
|
||||
}
|
||||
result.resumeConfirmation = await broker.confirmResume(
|
||||
context.transport.basisConnId,
|
||||
params.resumeConfirmReqId
|
||||
)
|
||||
}
|
||||
return result
|
||||
this.assertRelayHost(context, broker)
|
||||
const result: PairingGetEndpointsResult = { v: 1, relay: broker.endpoint }
|
||||
if (params.installReqId) {
|
||||
result.installStatus = await broker.credentialInstallStatus(
|
||||
context.deviceId,
|
||||
params.installReqId
|
||||
)
|
||||
}
|
||||
if (params.resumeConfirmReqId) {
|
||||
if (
|
||||
context.transport.transport !== 'relay' ||
|
||||
context.transport.credentialKind !== 'resume'
|
||||
) {
|
||||
throw new Error('resume_confirmation_unavailable')
|
||||
}
|
||||
result.resumeConfirmation = await broker.confirmResume(
|
||||
context.transport.basisConnId,
|
||||
params.resumeConfirmReqId
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
async provisionRelay(
|
||||
@@ -151,30 +169,42 @@ export class DesktopRelayService {
|
||||
params: PairingProvisionRelayParams
|
||||
): Promise<DeviceCredentialInstalled> {
|
||||
this.requireMobileDevice(context.deviceId)
|
||||
const broker = this.coordinator.getActiveBroker()
|
||||
if (!(broker instanceof RelaySessionBroker) || !broker.endpoint) {
|
||||
throw new Error('relay_control_not_active')
|
||||
}
|
||||
this.assertRelayHost(context, broker)
|
||||
const authorization = pairingAuthorizationForContext(context, broker.hostId)
|
||||
if (!authorization) {
|
||||
// Why: a resume splice proves renewal through confirmation; it cannot be
|
||||
// repurposed as either of the two initial-install authorization modes.
|
||||
throw new Error('relay_provision_authorization_unavailable')
|
||||
}
|
||||
if (
|
||||
!this.runtimeRpc.setMobileRelayBinding(context.deviceId, {
|
||||
relayHostId: broker.hostId,
|
||||
relayDeviceId: context.deviceId,
|
||||
ownerIdentityKey: broker.ownerIdentityKey
|
||||
})
|
||||
) {
|
||||
throw new Error('mobile_device_not_found')
|
||||
}
|
||||
return await broker.installCredential(context.deviceId, params, authorization)
|
||||
return await this.withTransientDemand(`provision:${context.deviceId}`, async () => {
|
||||
const broker = await this.requireActiveBroker()
|
||||
if (!broker.endpoint) {
|
||||
throw new Error('relay_control_not_active')
|
||||
}
|
||||
this.assertRelayHost(context, broker)
|
||||
const authorization = pairingAuthorizationForContext(context, broker.hostId)
|
||||
if (!authorization) {
|
||||
// Why: a resume splice proves renewal through confirmation; it cannot be
|
||||
// repurposed as either of the two initial-install authorization modes.
|
||||
throw new Error('relay_provision_authorization_unavailable')
|
||||
}
|
||||
if (
|
||||
!this.runtimeRpc.setMobileRelayBinding(context.deviceId, {
|
||||
relayHostId: broker.hostId,
|
||||
relayDeviceId: context.deviceId,
|
||||
ownerIdentityKey: broker.ownerIdentityKey
|
||||
})
|
||||
) {
|
||||
throw new Error('mobile_device_not_found')
|
||||
}
|
||||
this.refreshDemand()
|
||||
return await broker.installCredential(context.deviceId, params, authorization)
|
||||
})
|
||||
}
|
||||
|
||||
demandStateChanged(): void {
|
||||
this.refreshDemand()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
if (this.demandExpiryTimer) {
|
||||
clearTimeout(this.demandExpiryTimer)
|
||||
this.demandExpiryTimer = null
|
||||
}
|
||||
this.coordinator.stop()
|
||||
}
|
||||
|
||||
@@ -209,9 +239,55 @@ export class DesktopRelayService {
|
||||
try {
|
||||
await broker.revokeDevice(item.relayDeviceId, item.reqId)
|
||||
this.revokeOutbox.remove(item.reqId)
|
||||
this.refreshDemand()
|
||||
} catch {
|
||||
// Why: the durable item is the source of truth; reconnecting the same
|
||||
// account/control retries this stable reqId without delaying local revoke.
|
||||
}
|
||||
}
|
||||
|
||||
private async withTransientDemand<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||
const release = this.demandLedger.acquireTransient(key)
|
||||
this.refreshDemand()
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
release()
|
||||
this.refreshDemand()
|
||||
}
|
||||
}
|
||||
|
||||
private async activeBrokerForDemand(): Promise<RelaySessionBroker | null> {
|
||||
const broker =
|
||||
this.coordinator.getActiveBroker() ?? (await this.coordinator.waitForActiveBroker())
|
||||
return broker instanceof RelaySessionBroker ? broker : null
|
||||
}
|
||||
|
||||
private async requireActiveBroker(): Promise<RelaySessionBroker> {
|
||||
const broker = await this.activeBrokerForDemand()
|
||||
if (!broker) {
|
||||
throw new Error('relay_control_not_active')
|
||||
}
|
||||
return broker
|
||||
}
|
||||
|
||||
private refreshDemand(): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
if (this.demandExpiryTimer) {
|
||||
clearTimeout(this.demandExpiryTimer)
|
||||
this.demandExpiryTimer = null
|
||||
}
|
||||
this.coordinator.reconcile()
|
||||
const expiresAt = this.demandLedger.nextPendingExpiry()
|
||||
if (expiresAt !== null) {
|
||||
// Why: an unscanned QR must stop holding a standing control when its
|
||||
// server invite expires, even if no renderer survives to report closure.
|
||||
this.demandExpiryTimer = setTimeout(
|
||||
() => this.refreshDemand(),
|
||||
Math.max(1, expiresAt - Date.now() + 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,82 @@ const context: RelayAuthContext = {
|
||||
}
|
||||
|
||||
describe('RelayAuthCoordinator', () => {
|
||||
it('stays signed-in but does not open a broker without relay demand', async () => {
|
||||
const openBroker = vi.fn()
|
||||
const statuses: string[] = []
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => context,
|
||||
hasDemand: () => false,
|
||||
openBroker,
|
||||
onStatus: (status) => statuses.push(status)
|
||||
})
|
||||
coordinator.reconcile()
|
||||
await coordinator.waitForActiveBroker()
|
||||
expect(openBroker).not.toHaveBeenCalled()
|
||||
expect(statuses.at(-1)).toBe('standby')
|
||||
})
|
||||
|
||||
it('opens on demand and lingers before closing the last control', async () => {
|
||||
let demanded = false
|
||||
const broker = { closeNow: vi.fn() }
|
||||
const statuses: string[] = []
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => context,
|
||||
hasDemand: () => demanded,
|
||||
openBroker: async () => broker,
|
||||
onStatus: (status) => statuses.push(status),
|
||||
lingerMs: 250
|
||||
})
|
||||
coordinator.reconcile()
|
||||
await coordinator.waitForActiveBroker()
|
||||
demanded = true
|
||||
coordinator.reconcile()
|
||||
await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker)
|
||||
demanded = false
|
||||
coordinator.reconcile()
|
||||
await vi.waitFor(() => expect(statuses.at(-1)).toBe('standby'))
|
||||
expect(broker.closeNow).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(broker.closeNow).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('cancels linger when demand returns', async () => {
|
||||
let demanded = true
|
||||
const broker = { closeNow: vi.fn() }
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => context,
|
||||
hasDemand: () => demanded,
|
||||
openBroker: async () => broker,
|
||||
onStatus: vi.fn(),
|
||||
lingerMs: 20
|
||||
})
|
||||
coordinator.reconcile()
|
||||
await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker)
|
||||
demanded = false
|
||||
coordinator.reconcile()
|
||||
demanded = true
|
||||
coordinator.reconcile()
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
expect(broker.closeNow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not carry old-profile demand through an identity switch', async () => {
|
||||
let current = context
|
||||
const broker = { closeNow: vi.fn() }
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => current,
|
||||
hasDemand: ({ identity }) => identity.profileId === 'profile-1',
|
||||
openBroker: async () => broker,
|
||||
onStatus: vi.fn(),
|
||||
lingerMs: 10_000
|
||||
})
|
||||
coordinator.reconcile()
|
||||
await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker)
|
||||
current = { ...context, identity: { ...context.identity, profileId: 'profile-2' } }
|
||||
coordinator.reconcile()
|
||||
await coordinator.waitForActiveBroker()
|
||||
expect(broker.closeNow).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fences a session read that finishes after sign-out', async () => {
|
||||
const read = deferred<RelayAuthContext | null>()
|
||||
const openBroker = vi.fn()
|
||||
|
||||
@@ -18,12 +18,14 @@ export type CoordinatedRelayBroker = {
|
||||
|
||||
type RelayAuthCoordinatorOptions = {
|
||||
readContext: () => Promise<RelayAuthContext | null>
|
||||
hasDemand?: (context: RelayAuthContext) => boolean
|
||||
openBroker: (input: {
|
||||
context: RelayAuthContext
|
||||
isCurrent: () => boolean
|
||||
refreshAccessToken: () => Promise<string | null>
|
||||
}) => Promise<CoordinatedRelayBroker>
|
||||
onStatus: (status: RelayBrokerStatus) => void
|
||||
lingerMs?: number
|
||||
}
|
||||
|
||||
type BrokerOwnership = {
|
||||
@@ -41,6 +43,8 @@ export class RelayAuthCoordinator {
|
||||
private authEpoch = 0
|
||||
private ownership: BrokerOwnership | null = null
|
||||
private readonly pendingOwnerships = new Set<BrokerOwnership>()
|
||||
private latestReconcile: Promise<void> = Promise.resolve()
|
||||
private lingerTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private stopped = false
|
||||
|
||||
constructor(options: RelayAuthCoordinatorOptions) {
|
||||
@@ -53,11 +57,14 @@ export class RelayAuthCoordinator {
|
||||
}
|
||||
const epoch = ++this.authEpoch
|
||||
this.invalidatePendingOwnerships()
|
||||
void this.reconcileEpoch(epoch)
|
||||
const reconcile = this.reconcileEpoch(epoch)
|
||||
this.latestReconcile = reconcile
|
||||
void reconcile
|
||||
}
|
||||
|
||||
fenceAndCloseNow(): void {
|
||||
++this.authEpoch
|
||||
this.cancelLinger()
|
||||
this.invalidatePendingOwnerships()
|
||||
this.invalidateOwnership()
|
||||
this.options.onStatus('offline')
|
||||
@@ -67,6 +74,21 @@ export class RelayAuthCoordinator {
|
||||
return this.ownership?.valid ? this.ownership.broker : null
|
||||
}
|
||||
|
||||
async waitForActiveBroker(): Promise<CoordinatedRelayBroker | null> {
|
||||
while (!this.stopped) {
|
||||
const broker = this.getActiveBroker()
|
||||
if (broker) {
|
||||
return broker
|
||||
}
|
||||
const pending = this.latestReconcile
|
||||
await pending
|
||||
if (pending === this.latestReconcile) {
|
||||
return this.getActiveBroker()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
this.fenceAndCloseNow()
|
||||
@@ -79,12 +101,25 @@ export class RelayAuthCoordinator {
|
||||
return
|
||||
}
|
||||
if (!context || !context.relayEntitled) {
|
||||
this.cancelLinger()
|
||||
this.invalidateOwnership()
|
||||
this.options.onStatus('offline')
|
||||
return
|
||||
}
|
||||
const nextIdentityKey = identityKey(context.identity)
|
||||
if (!(this.options.hasDemand?.(context) ?? true)) {
|
||||
if (this.ownership?.valid && this.ownership.identityKey !== nextIdentityKey) {
|
||||
this.cancelLinger()
|
||||
this.invalidateOwnership()
|
||||
} else if (this.ownership?.valid) {
|
||||
this.scheduleLinger(context, this.ownership)
|
||||
}
|
||||
this.options.onStatus('standby')
|
||||
return
|
||||
}
|
||||
this.cancelLinger()
|
||||
if (this.ownership?.valid && this.ownership.identityKey === nextIdentityKey) {
|
||||
this.options.onStatus('registered')
|
||||
return
|
||||
}
|
||||
this.invalidateOwnership()
|
||||
@@ -152,6 +187,31 @@ export class RelayAuthCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleLinger(context: RelayAuthContext, ownership: BrokerOwnership): void {
|
||||
if (this.lingerTimer) {
|
||||
return
|
||||
}
|
||||
const lingerMs = this.options.lingerMs ?? 10 * 60_000
|
||||
this.lingerTimer = setTimeout(() => {
|
||||
this.lingerTimer = null
|
||||
if (
|
||||
this.ownership === ownership &&
|
||||
ownership.valid &&
|
||||
!(this.options.hasDemand?.(context) ?? true)
|
||||
) {
|
||||
this.invalidateOwnership()
|
||||
this.options.onStatus('standby')
|
||||
}
|
||||
}, lingerMs)
|
||||
}
|
||||
|
||||
private cancelLinger(): void {
|
||||
if (this.lingerTimer) {
|
||||
clearTimeout(this.lingerTimer)
|
||||
this.lingerTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private invalidatePendingOwnerships(): void {
|
||||
for (const ownership of this.pendingOwnerships) {
|
||||
ownership.valid = false
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DeviceRegistry } from '../device-registry'
|
||||
import { RelayDemandLedger } from './relay-demand-ledger'
|
||||
import { RelayRevokeOutbox, type RelayDeviceBinding } from './relay-revoke-outbox'
|
||||
|
||||
const ownerIdentityKey = 'user-1\0profile-1\0org-1'
|
||||
const relayHostId = 'relay-host-1'
|
||||
|
||||
function fixture(now: number) {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-relay-demand-'))
|
||||
const deviceRegistry = new DeviceRegistry(userDataPath)
|
||||
const revokeOutbox = new RelayRevokeOutbox(userDataPath)
|
||||
const ledger = new RelayDemandLedger({
|
||||
deviceRegistry,
|
||||
revokeOutbox,
|
||||
relayHostId,
|
||||
now: () => now
|
||||
})
|
||||
return { userDataPath, deviceRegistry, revokeOutbox, ledger }
|
||||
}
|
||||
|
||||
function binding(relayDeviceId: string, inviteExpiresAt?: number): RelayDeviceBinding {
|
||||
return { relayDeviceId, relayHostId, ownerIdentityKey, inviteExpiresAt }
|
||||
}
|
||||
|
||||
describe('RelayDemandLedger', () => {
|
||||
it('reference-counts concurrent main-process work', () => {
|
||||
const { ledger } = fixture(1_000)
|
||||
const releaseFirst = ledger.acquireTransient('pairing:device-1')
|
||||
const releaseSecond = ledger.acquireTransient('pairing:device-1')
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
|
||||
releaseFirst()
|
||||
releaseFirst()
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
|
||||
releaseSecond()
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('holds pending QR demand only through invite expiry', () => {
|
||||
const { userDataPath, deviceRegistry, ledger } = fixture(1_000)
|
||||
const pending = deviceRegistry.addDevice('Pending phone')
|
||||
deviceRegistry.setRelayBinding(pending.deviceId, binding(pending.deviceId, 2_000))
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
|
||||
expect(ledger.nextPendingExpiry()).toBe(2_000)
|
||||
const restarted = new RelayDemandLedger({
|
||||
deviceRegistry: new DeviceRegistry(userDataPath),
|
||||
revokeOutbox: new RelayRevokeOutbox(userDataPath),
|
||||
relayHostId,
|
||||
now: () => 1_500
|
||||
})
|
||||
expect(restarted.hasDemand(ownerIdentityKey)).toBe(true)
|
||||
|
||||
const expiredFixture = fixture(3_000)
|
||||
const expired = expiredFixture.deviceRegistry.addDevice('Expired phone')
|
||||
expiredFixture.deviceRegistry.setRelayBinding(
|
||||
expired.deviceId,
|
||||
binding(expired.deviceId, 2_000)
|
||||
)
|
||||
expect(expiredFixture.ledger.hasDemand(ownerIdentityKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not promote a scanned invite to provisioned demand before install', () => {
|
||||
const { deviceRegistry, ledger } = fixture(3_000)
|
||||
const scanned = deviceRegistry.addDevice('Scanned phone')
|
||||
deviceRegistry.setRelayBinding(scanned.deviceId, binding(scanned.deviceId, 2_000))
|
||||
deviceRegistry.updateLastSeen(scanned.deviceId)
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps provisioned devices and revoke outbox work authoritative', () => {
|
||||
const { deviceRegistry, revokeOutbox, ledger } = fixture(5_000)
|
||||
const paired = deviceRegistry.addDevice('Paired phone')
|
||||
deviceRegistry.setRelayBinding(paired.deviceId, binding(paired.deviceId))
|
||||
deviceRegistry.updateLastSeen(paired.deviceId)
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
|
||||
|
||||
deviceRegistry.removeDevice(paired.deviceId)
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(false)
|
||||
revokeOutbox.enqueue(binding(paired.deviceId))
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not activate another signed-in identity or relay host', () => {
|
||||
const { deviceRegistry, ledger } = fixture(1_000)
|
||||
const pending = deviceRegistry.addDevice('Other phone')
|
||||
deviceRegistry.setRelayBinding(pending.deviceId, {
|
||||
...binding(pending.deviceId, 2_000),
|
||||
ownerIdentityKey: 'other-user\0profile\0org'
|
||||
})
|
||||
expect(ledger.hasDemand(ownerIdentityKey)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { DeviceRegistry } from '../device-registry'
|
||||
import type { RelayRevokeOutbox } from './relay-revoke-outbox'
|
||||
|
||||
type RelayDemandLedgerOptions = {
|
||||
deviceRegistry: DeviceRegistry
|
||||
revokeOutbox: RelayRevokeOutbox
|
||||
relayHostId: string
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export class RelayDemandLedger {
|
||||
private readonly options: RelayDemandLedgerOptions
|
||||
private readonly transientRefs = new Map<string, number>()
|
||||
|
||||
constructor(options: RelayDemandLedgerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
acquireTransient(key: string): () => void {
|
||||
this.transientRefs.set(key, (this.transientRefs.get(key) ?? 0) + 1)
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
const count = this.transientRefs.get(key) ?? 0
|
||||
if (count <= 1) {
|
||||
this.transientRefs.delete(key)
|
||||
} else {
|
||||
this.transientRefs.set(key, count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasDemand(ownerIdentityKey: string): boolean {
|
||||
if (this.transientRefs.size > 0) {
|
||||
return true
|
||||
}
|
||||
if (this.options.revokeOutbox.pendingFor(ownerIdentityKey, this.options.relayHostId).length) {
|
||||
return true
|
||||
}
|
||||
const now = (this.options.now ?? Date.now)()
|
||||
return this.options.deviceRegistry.listDevices().some((device) => {
|
||||
const binding = device.relayBinding
|
||||
if (
|
||||
device.scope !== 'mobile' ||
|
||||
!binding ||
|
||||
binding.ownerIdentityKey !== ownerIdentityKey ||
|
||||
binding.relayHostId !== this.options.relayHostId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: E2EE authentication marks a scanned DeviceEntry as seen before
|
||||
// relay credential install commits. Only removing the invite expiry at
|
||||
// the durable install boundary promotes it to standing device demand.
|
||||
return binding.inviteExpiresAt === undefined || binding.inviteExpiresAt > now
|
||||
})
|
||||
}
|
||||
|
||||
nextPendingExpiry(): number | null {
|
||||
const now = (this.options.now ?? Date.now)()
|
||||
let next: number | null = null
|
||||
for (const device of this.options.deviceRegistry.listDevices()) {
|
||||
const expiresAt = device.relayBinding?.inviteExpiresAt
|
||||
if (expiresAt && expiresAt > now && (next === null || expiresAt < next)) {
|
||||
next = expiresAt
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type RelayDeviceBinding = {
|
||||
relayHostId: string
|
||||
relayDeviceId: string
|
||||
ownerIdentityKey: string
|
||||
inviteExpiresAt?: number
|
||||
}
|
||||
|
||||
export type RelayRevokeOutboxItem = RelayDeviceBinding & {
|
||||
@@ -26,6 +27,8 @@ function isItem(value: unknown): value is RelayRevokeOutboxItem {
|
||||
typeof item.relayHostId === 'string' &&
|
||||
typeof item.relayDeviceId === 'string' &&
|
||||
typeof item.ownerIdentityKey === 'string' &&
|
||||
(item.inviteExpiresAt === undefined ||
|
||||
(typeof item.inviteExpiresAt === 'number' && Number.isFinite(item.inviteExpiresAt))) &&
|
||||
typeof item.createdAt === 'number' &&
|
||||
Number.isFinite(item.createdAt)
|
||||
)
|
||||
|
||||
@@ -66,6 +66,7 @@ type MobileRelayPairingProvider = {
|
||||
relayDeviceId: string
|
||||
): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }>
|
||||
onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void
|
||||
onDemandStateChanged?(): void
|
||||
getEndpoints(
|
||||
context: MobilePairingConnectionContext,
|
||||
params: PairingGetEndpointsParams
|
||||
@@ -526,7 +527,11 @@ export class OrcaRuntimeRpcServer {
|
||||
// strand the old cloud credential family even if that account is offline.
|
||||
this.queueRelayDeviceRevoke(current.relayBinding)
|
||||
}
|
||||
return this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false
|
||||
const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false
|
||||
if (updated) {
|
||||
this.mobileRelayPairingProvider?.onDemandStateChanged?.()
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void {
|
||||
@@ -544,6 +549,7 @@ export class OrcaRuntimeRpcServer {
|
||||
if (!this.deviceRegistry?.removeDevice(deviceId)) {
|
||||
return false
|
||||
}
|
||||
this.mobileRelayPairingProvider?.onDemandStateChanged?.()
|
||||
this.mobileSocketWiring?.terminateDeviceConnections(device.token)
|
||||
return true
|
||||
}
|
||||
@@ -632,6 +638,7 @@ export class OrcaRuntimeRpcServer {
|
||||
if (!this.deviceRegistry?.setRelayBinding(device.deviceId, relayPairing.binding)) {
|
||||
return direct
|
||||
}
|
||||
this.mobileRelayPairingProvider.onDemandStateChanged?.()
|
||||
return {
|
||||
...direct,
|
||||
pairingUrl: encodePairingOffer({
|
||||
@@ -849,6 +856,7 @@ export class OrcaRuntimeRpcServer {
|
||||
)
|
||||
},
|
||||
onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws),
|
||||
onReady: () => this.mobileRelayPairingProvider?.onDemandStateChanged?.(),
|
||||
onClose: (socket, hasOtherConnections) => {
|
||||
if (!socket) {
|
||||
return
|
||||
|
||||
@@ -87,6 +87,8 @@ describe('MobileRelayStatusSection', () => {
|
||||
await waitFor(() => expect(screen.getByText('Registered')).toBeVisible())
|
||||
statusListener?.('offline')
|
||||
await waitFor(() => expect(screen.getByText('Offline')).toBeVisible())
|
||||
statusListener?.('standby')
|
||||
await waitFor(() => expect(screen.getByText('Standby — no relay devices')).toBeVisible())
|
||||
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,12 @@ function relayStatusLabel(status: MobileRelayStatus): string {
|
||||
if (status === 'connecting') {
|
||||
return translate('auto.components.settings.MobileRelayStatusSection.connecting', 'Connecting')
|
||||
}
|
||||
if (status === 'standby') {
|
||||
return translate(
|
||||
'auto.components.settings.MobileRelayStatusSection.standby',
|
||||
'Standby — no relay devices'
|
||||
)
|
||||
}
|
||||
if (status === 'draining') {
|
||||
return translate(
|
||||
'auto.components.settings.MobileRelayStatusSection.reconnecting',
|
||||
@@ -64,7 +70,7 @@ export function MobileRelayStatusSection(): React.JSX.Element {
|
||||
{signedIn
|
||||
? translate(
|
||||
'auto.components.settings.MobileRelayStatusSection.automatic',
|
||||
'Connect from anywhere automatically'
|
||||
'Connect from anywhere when a phone is paired'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.MobileRelayStatusSection.signInPrompt',
|
||||
|
||||
@@ -9039,12 +9039,13 @@
|
||||
"reconnecting": "Reconnecting",
|
||||
"offline": "Offline",
|
||||
"title": "Orca Relay",
|
||||
"automatic": "Connect from anywhere automatically",
|
||||
"automatic": "Connect from anywhere when a phone is paired",
|
||||
"signInPrompt": "Sign in on this desktop to connect from anywhere",
|
||||
"directStillAvailable": "LAN and Tailscale connections remain available.",
|
||||
"directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.",
|
||||
"signIn": "Sign in",
|
||||
"unavailable": "Unavailable"
|
||||
"unavailable": "Unavailable",
|
||||
"standby": "Standby — no relay devices"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -9039,12 +9039,13 @@
|
||||
"reconnecting": "Reconnecting",
|
||||
"offline": "Offline",
|
||||
"title": "Orca Relay",
|
||||
"automatic": "Connect from anywhere automatically",
|
||||
"automatic": "Connect from anywhere when a phone is paired",
|
||||
"signInPrompt": "Sign in on this desktop to connect from anywhere",
|
||||
"directStillAvailable": "LAN and Tailscale connections remain available.",
|
||||
"directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.",
|
||||
"signIn": "Sign in",
|
||||
"unavailable": "Unavailable"
|
||||
"unavailable": "Unavailable",
|
||||
"standby": "Standby — no relay devices"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -9039,12 +9039,13 @@
|
||||
"reconnecting": "Reconnecting",
|
||||
"offline": "Offline",
|
||||
"title": "Orca Relay",
|
||||
"automatic": "Connect from anywhere automatically",
|
||||
"automatic": "Connect from anywhere when a phone is paired",
|
||||
"signInPrompt": "Sign in on this desktop to connect from anywhere",
|
||||
"directStillAvailable": "LAN and Tailscale connections remain available.",
|
||||
"directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.",
|
||||
"signIn": "Sign in",
|
||||
"unavailable": "Unavailable"
|
||||
"unavailable": "Unavailable",
|
||||
"standby": "Standby — no relay devices"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -9039,12 +9039,13 @@
|
||||
"reconnecting": "Reconnecting",
|
||||
"offline": "Offline",
|
||||
"title": "Orca Relay",
|
||||
"automatic": "Connect from anywhere automatically",
|
||||
"automatic": "Connect from anywhere when a phone is paired",
|
||||
"signInPrompt": "Sign in on this desktop to connect from anywhere",
|
||||
"directStillAvailable": "LAN and Tailscale connections remain available.",
|
||||
"directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.",
|
||||
"signIn": "Sign in",
|
||||
"unavailable": "Unavailable"
|
||||
"unavailable": "Unavailable",
|
||||
"standby": "Standby — no relay devices"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -9039,12 +9039,13 @@
|
||||
"reconnecting": "Reconnecting",
|
||||
"offline": "Offline",
|
||||
"title": "Orca Relay",
|
||||
"automatic": "Connect from anywhere automatically",
|
||||
"automatic": "Connect from anywhere when a phone is paired",
|
||||
"signInPrompt": "Sign in on this desktop to connect from anywhere",
|
||||
"directStillAvailable": "LAN and Tailscale connections remain available.",
|
||||
"directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.",
|
||||
"signIn": "Sign in",
|
||||
"unavailable": "Unavailable"
|
||||
"unavailable": "Unavailable",
|
||||
"standby": "Standby — no relay devices"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export const MOBILE_RELAY_STATUSES = ['connecting', 'registered', 'draining', 'offline'] as const
|
||||
export const MOBILE_RELAY_STATUSES = [
|
||||
'connecting',
|
||||
'registered',
|
||||
'standby',
|
||||
'draining',
|
||||
'offline'
|
||||
] as const
|
||||
|
||||
export type MobileRelayStatus = (typeof MOBILE_RELAY_STATUSES)[number]
|
||||
|
||||
Reference in New Issue
Block a user