mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
feat(runtime): add relay credential pairing RPCs
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
+3
-1
@@ -2163,7 +2163,9 @@ app.whenReady().then(async () => {
|
||||
desktopRelayService = relayService
|
||||
runtimeRpc.setMobileRelayPairingProvider({
|
||||
createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId),
|
||||
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item)
|
||||
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
|
||||
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
|
||||
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
|
||||
})
|
||||
relayService.start()
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MobilePairingConnectionContext } from '../runtime-rpc'
|
||||
import { pairingAuthorizationForContext } from './desktop-relay-service'
|
||||
|
||||
const relayHostId = 'AbCdEf0123_-xyZ9'
|
||||
|
||||
function context(
|
||||
transport: MobilePairingConnectionContext['transport']
|
||||
): MobilePairingConnectionContext {
|
||||
return { deviceId: 'device-1', connectionId: 'e2ee-connection-1', transport }
|
||||
}
|
||||
|
||||
describe('pairingAuthorizationForContext', () => {
|
||||
it('derives direct authorization only from the authenticated connection', () => {
|
||||
expect(pairingAuthorizationForContext(context({ transport: 'direct' }), relayHostId)).toEqual({
|
||||
mode: 'authenticated-direct',
|
||||
directAuthId: 'e2ee-connection-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('derives invite authorization only from immutable relay metadata', () => {
|
||||
expect(
|
||||
pairingAuthorizationForContext(
|
||||
context({
|
||||
transport: 'relay',
|
||||
relayHostId,
|
||||
relayDeviceId: 'device-1',
|
||||
basisConnId: 'relay-basis-1',
|
||||
credentialKind: 'invite'
|
||||
}),
|
||||
relayHostId
|
||||
)
|
||||
).toEqual({ mode: 'relay-basis', basisConnId: 'relay-basis-1' })
|
||||
})
|
||||
|
||||
it('reserves resume metadata for confirmation and rejects stale hosts', () => {
|
||||
expect(
|
||||
pairingAuthorizationForContext(
|
||||
context({
|
||||
transport: 'relay',
|
||||
relayHostId,
|
||||
relayDeviceId: 'device-1',
|
||||
basisConnId: 'resume-basis-1',
|
||||
credentialKind: 'resume'
|
||||
}),
|
||||
relayHostId
|
||||
)
|
||||
).toBeNull()
|
||||
expect(() =>
|
||||
pairingAuthorizationForContext(
|
||||
context({
|
||||
transport: 'relay',
|
||||
relayHostId: 'stale-host-id-1',
|
||||
relayDeviceId: 'device-1',
|
||||
basisConnId: 'relay-basis-1',
|
||||
credentialKind: 'invite'
|
||||
}),
|
||||
relayHostId
|
||||
)
|
||||
).toThrow('stale_relay_connection')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config'
|
||||
import type { OrcaRuntimeRpcServer } from '../runtime-rpc'
|
||||
import type { MobilePairingConnectionContext, OrcaRuntimeRpcServer } from '../runtime-rpc'
|
||||
import type {
|
||||
DeviceCredentialInstalled,
|
||||
PairingGetEndpointsParams,
|
||||
PairingGetEndpointsResult,
|
||||
PairingProvisionRelayParams
|
||||
} from '../../../shared/mobile-relay-credential-contract'
|
||||
import { readRelayAuthContext } from './relay-auth-context'
|
||||
import { RelayAuthCoordinator } from './relay-auth-coordinator'
|
||||
import { RelaySessionBroker, type RelayBrokerStatus } from './relay-session-broker'
|
||||
@@ -9,6 +15,7 @@ import type {
|
||||
RelayDeviceBinding,
|
||||
RelayRevokeOutboxItem
|
||||
} from './relay-revoke-outbox'
|
||||
import type { DeviceCredentialInstallAuthorization } from './relay-control-requests'
|
||||
|
||||
type DesktopRelayServiceOptions = {
|
||||
authConfig: OrcaCloudAuthConfig
|
||||
@@ -18,9 +25,25 @@ type DesktopRelayServiceOptions = {
|
||||
onStatus: (status: RelayBrokerStatus) => void
|
||||
}
|
||||
|
||||
export function pairingAuthorizationForContext(
|
||||
context: MobilePairingConnectionContext,
|
||||
relayHostId: string
|
||||
): DeviceCredentialInstallAuthorization | null {
|
||||
if (context.transport.transport === 'direct') {
|
||||
return { mode: 'authenticated-direct', directAuthId: context.connectionId }
|
||||
}
|
||||
if (context.transport.relayHostId !== relayHostId) {
|
||||
throw new Error('stale_relay_connection')
|
||||
}
|
||||
return context.transport.credentialKind === 'invite'
|
||||
? { mode: 'relay-basis', basisConnId: context.transport.basisConnId }
|
||||
: null
|
||||
}
|
||||
|
||||
export class DesktopRelayService {
|
||||
private readonly coordinator: RelayAuthCoordinator
|
||||
private readonly revokeOutbox: RelayRevokeOutbox
|
||||
private readonly runtimeRpc: OrcaRuntimeRpcServer
|
||||
|
||||
constructor(options: DesktopRelayServiceOptions) {
|
||||
const keypair = options.runtimeRpc.getE2EEKeypair()
|
||||
@@ -28,6 +51,7 @@ export class DesktopRelayService {
|
||||
if (!keypair || !mobileSocketWiring) {
|
||||
throw new Error('mobile_runtime_not_ready')
|
||||
}
|
||||
this.runtimeRpc = options.runtimeRpc
|
||||
this.revokeOutbox = options.runtimeRpc.getRelayRevokeOutbox()
|
||||
this.coordinator = new RelayAuthCoordinator({
|
||||
readContext: () => readRelayAuthContext(options.authConfig, options.userDataPath),
|
||||
@@ -91,6 +115,66 @@ export class DesktopRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
async getEndpoints(
|
||||
context: MobilePairingConnectionContext,
|
||||
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')
|
||||
}
|
||||
result.resumeConfirmation = await broker.confirmResume(
|
||||
context.transport.basisConnId,
|
||||
params.resumeConfirmReqId
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async provisionRelay(
|
||||
context: MobilePairingConnectionContext,
|
||||
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)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.coordinator.stop()
|
||||
}
|
||||
@@ -101,6 +185,24 @@ export class DesktopRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
private requireMobileDevice(deviceId: string): void {
|
||||
if (this.runtimeRpc.getDeviceRegistry()?.getDevice(deviceId)?.scope !== 'mobile') {
|
||||
throw new Error('mobile_device_not_found')
|
||||
}
|
||||
}
|
||||
|
||||
private assertRelayHost(
|
||||
context: MobilePairingConnectionContext,
|
||||
broker: RelaySessionBroker
|
||||
): void {
|
||||
if (
|
||||
context.transport.transport === 'relay' &&
|
||||
context.transport.relayHostId !== broker.hostId
|
||||
) {
|
||||
throw new Error('stale_relay_connection')
|
||||
}
|
||||
}
|
||||
|
||||
private async flushRevoke(
|
||||
broker: RelaySessionBroker,
|
||||
item: RelayRevokeOutboxItem
|
||||
|
||||
@@ -242,6 +242,72 @@ describe('RelayControlClient', () => {
|
||||
)
|
||||
await expect(invitePromise).resolves.toMatchObject({ reqId: 'invite-req' })
|
||||
|
||||
const installRequest = nextJson(socket)
|
||||
const installPromise = client.installCredential({
|
||||
reqId: 'install-req',
|
||||
relayDeviceId: 'device-1',
|
||||
newResumeTokenHash: 'A'.repeat(43),
|
||||
authorization: { mode: 'relay-basis', basisConnId: 'conn-1' }
|
||||
})
|
||||
await expect(installRequest).resolves.toEqual({
|
||||
type: 'device-credential-install',
|
||||
v: 1,
|
||||
reqId: 'install-req',
|
||||
relayDeviceId: 'device-1',
|
||||
newResumeTokenHash: 'A'.repeat(43),
|
||||
authorization: { mode: 'relay-basis', basisConnId: 'conn-1' }
|
||||
})
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'device-credential-installed',
|
||||
v: 1,
|
||||
reqId: 'install-req',
|
||||
authorizationMode: 'relay-basis',
|
||||
currentVersion: 1,
|
||||
resumeExpiresAt: Date.now() + 60_000
|
||||
})
|
||||
)
|
||||
await expect(installPromise).resolves.toMatchObject({ currentVersion: 1 })
|
||||
|
||||
const statusRequest = nextJson(socket)
|
||||
const statusPromise = client.credentialInstallStatus('device-1', 'install-req')
|
||||
await expect(statusRequest).resolves.toEqual({
|
||||
type: 'device-credential-install-status',
|
||||
v: 1,
|
||||
reqId: 'install-req',
|
||||
relayDeviceId: 'device-1'
|
||||
})
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'device-credential-install-status-result',
|
||||
v: 1,
|
||||
reqId: 'install-req',
|
||||
state: 'not-found'
|
||||
})
|
||||
)
|
||||
await expect(statusPromise).resolves.toMatchObject({ state: 'not-found' })
|
||||
|
||||
const confirmationRequest = nextJson(socket)
|
||||
const confirmationPromise = client.confirmResume('conn-2', 'confirm-req')
|
||||
await expect(confirmationRequest).resolves.toEqual({
|
||||
type: 'device-resume-confirm',
|
||||
v: 1,
|
||||
reqId: 'confirm-req',
|
||||
basisConnId: 'conn-2'
|
||||
})
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'device-resume-confirmed',
|
||||
v: 1,
|
||||
reqId: 'confirm-req',
|
||||
currentVersion: 1,
|
||||
acceptedAs: 'current',
|
||||
renewed: true,
|
||||
resumeExpiresAt: Date.now() + 60_000
|
||||
})
|
||||
)
|
||||
await expect(confirmationPromise).resolves.toMatchObject({ renewed: true })
|
||||
|
||||
socket.send(JSON.stringify({ type: 'drain', graceMs: 5_000, recovery: 'resolve-director' }))
|
||||
await vi.waitFor(() => expect(onDrain).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type RelayInviteCreatedMessage
|
||||
} from './relay-control-protocol'
|
||||
import { RelayControlRequests } from './relay-control-requests'
|
||||
import type { DeviceCredentialInstallAuthorization } from './relay-control-requests'
|
||||
import { answerRelayHostChallenge } from './relay-host-proof'
|
||||
|
||||
type RelayControlState = 'idle' | 'opening' | 'proving' | 'active' | 'draining' | 'closed'
|
||||
@@ -120,6 +121,33 @@ export class RelayControlClient {
|
||||
return this.requests.revokeDevice(reqId, relayDeviceId, (payload) => this.sendActive(payload))
|
||||
}
|
||||
|
||||
installCredential(input: {
|
||||
reqId: string
|
||||
relayDeviceId: string
|
||||
newResumeTokenHash: string
|
||||
expectedCurrentHash?: string
|
||||
authorization: DeviceCredentialInstallAuthorization
|
||||
}): ReturnType<RelayControlRequests['installCredential']> {
|
||||
const { reqId, ...request } = input
|
||||
return this.requests.installCredential(reqId, request, (payload) => this.sendActive(payload))
|
||||
}
|
||||
|
||||
credentialInstallStatus(
|
||||
relayDeviceId: string,
|
||||
reqId: string
|
||||
): ReturnType<RelayControlRequests['credentialInstallStatus']> {
|
||||
return this.requests.credentialInstallStatus(reqId, relayDeviceId, (payload) =>
|
||||
this.sendActive(payload)
|
||||
)
|
||||
}
|
||||
|
||||
confirmResume(
|
||||
basisConnId: string,
|
||||
reqId: string
|
||||
): ReturnType<RelayControlRequests['confirmResume']> {
|
||||
return this.requests.confirmResume(reqId, basisConnId, (payload) => this.sendActive(payload))
|
||||
}
|
||||
|
||||
closeNow(): void {
|
||||
this.state = 'closed'
|
||||
this.requests.rejectAll(new Error('relay_control_closed'))
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import type { RawData } from 'ws'
|
||||
import {
|
||||
DeviceCredentialInstalledSchema,
|
||||
DeviceResumeConfirmedSchema
|
||||
} from '../../../shared/mobile-relay-credential-contract'
|
||||
|
||||
const OpaqueIdSchema = z.string().min(1).max(128)
|
||||
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
|
||||
@@ -79,6 +83,34 @@ export const RelayDeviceRevokedMessageSchema = z
|
||||
.object({ type: z.literal('device-revoked'), reqId: OpaqueIdSchema })
|
||||
.strict()
|
||||
|
||||
export const RelayDeviceCredentialInstalledMessageSchema = DeviceCredentialInstalledSchema.extend({
|
||||
type: z.literal('device-credential-installed')
|
||||
}).strict()
|
||||
|
||||
export const RelayDeviceCredentialInstallStatusResultMessageSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
type: z.literal('device-credential-install-status-result'),
|
||||
v: z.literal(1),
|
||||
reqId: OpaqueIdSchema,
|
||||
state: z.literal('not-found')
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('device-credential-install-status-result'),
|
||||
v: z.literal(1),
|
||||
reqId: OpaqueIdSchema,
|
||||
state: z.literal('committed'),
|
||||
result: DeviceCredentialInstalledSchema
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
export const RelayDeviceResumeConfirmedMessageSchema = DeviceResumeConfirmedSchema.extend({
|
||||
type: z.literal('device-resume-confirmed')
|
||||
}).strict()
|
||||
|
||||
export const RelayControlErrorMessageSchema = z
|
||||
.object({
|
||||
type: z.literal('control-error'),
|
||||
@@ -92,6 +124,15 @@ export type RelayHostHelloAckMessage = z.infer<typeof RelayHostHelloAckMessageSc
|
||||
export type RelayConnectionOpenMessage = z.infer<typeof RelayConnectionOpenMessageSchema>
|
||||
export type RelayDrainMessage = z.infer<typeof RelayDrainMessageSchema>
|
||||
export type RelayInviteCreatedMessage = z.infer<typeof RelayInviteCreatedMessageSchema>
|
||||
export type RelayDeviceCredentialInstalledMessage = z.infer<
|
||||
typeof RelayDeviceCredentialInstalledMessageSchema
|
||||
>
|
||||
export type RelayDeviceCredentialInstallStatusResultMessage = z.infer<
|
||||
typeof RelayDeviceCredentialInstallStatusResultMessageSchema
|
||||
>
|
||||
export type RelayDeviceResumeConfirmedMessage = z.infer<
|
||||
typeof RelayDeviceResumeConfirmedMessageSchema
|
||||
>
|
||||
|
||||
export function parseRelayControlMessage(raw: RawData): Record<string, unknown> | null {
|
||||
try {
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import {
|
||||
RelayControlErrorMessageSchema,
|
||||
RelayDeviceCredentialInstalledMessageSchema,
|
||||
RelayDeviceCredentialInstallStatusResultMessageSchema,
|
||||
RelayDeviceRevokedMessageSchema,
|
||||
RelayDeviceResumeConfirmedMessageSchema,
|
||||
RelayInviteCreatedMessageSchema,
|
||||
type RelayDeviceCredentialInstalledMessage,
|
||||
type RelayDeviceCredentialInstallStatusResultMessage,
|
||||
type RelayDeviceResumeConfirmedMessage,
|
||||
type RelayInviteCreatedMessage
|
||||
} from './relay-control-protocol'
|
||||
|
||||
type PendingRequest = {
|
||||
kind: 'invite' | 'revoke'
|
||||
kind: 'invite' | 'revoke' | 'install' | 'install-status' | 'confirm'
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export type DeviceCredentialInstallAuthorization =
|
||||
| { mode: 'relay-basis'; basisConnId: string }
|
||||
| { mode: 'authenticated-direct'; directAuthId: string }
|
||||
|
||||
export class RelayControlRequests {
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
|
||||
@@ -41,6 +51,50 @@ export class RelayControlRequests {
|
||||
) as Promise<void>
|
||||
}
|
||||
|
||||
installCredential(
|
||||
reqId: string,
|
||||
input: {
|
||||
relayDeviceId: string
|
||||
newResumeTokenHash: string
|
||||
expectedCurrentHash?: string
|
||||
authorization: DeviceCredentialInstallAuthorization
|
||||
},
|
||||
send: (payload: object) => void
|
||||
): Promise<RelayDeviceCredentialInstalledMessage> {
|
||||
return this.request(
|
||||
reqId,
|
||||
'install',
|
||||
{ type: 'device-credential-install', v: 1, reqId, ...input },
|
||||
send
|
||||
) as Promise<RelayDeviceCredentialInstalledMessage>
|
||||
}
|
||||
|
||||
credentialInstallStatus(
|
||||
reqId: string,
|
||||
relayDeviceId: string,
|
||||
send: (payload: object) => void
|
||||
): Promise<RelayDeviceCredentialInstallStatusResultMessage> {
|
||||
return this.request(
|
||||
reqId,
|
||||
'install-status',
|
||||
{ type: 'device-credential-install-status', v: 1, reqId, relayDeviceId },
|
||||
send
|
||||
) as Promise<RelayDeviceCredentialInstallStatusResultMessage>
|
||||
}
|
||||
|
||||
confirmResume(
|
||||
reqId: string,
|
||||
basisConnId: string,
|
||||
send: (payload: object) => void
|
||||
): Promise<RelayDeviceResumeConfirmedMessage> {
|
||||
return this.request(
|
||||
reqId,
|
||||
'confirm',
|
||||
{ type: 'device-resume-confirm', v: 1, reqId, basisConnId },
|
||||
send
|
||||
) as Promise<RelayDeviceResumeConfirmedMessage>
|
||||
}
|
||||
|
||||
resolveMessage(message: Record<string, unknown>): boolean {
|
||||
const reqId = typeof message.reqId === 'string' ? message.reqId : null
|
||||
const pending = reqId ? this.pending.get(reqId) : null
|
||||
@@ -62,12 +116,27 @@ export class RelayControlRequests {
|
||||
pending.resolve(invite.data)
|
||||
return true
|
||||
}
|
||||
const revoked = RelayDeviceRevokedMessageSchema.safeParse(message)
|
||||
if (!revoked.success) {
|
||||
if (pending.kind === 'revoke') {
|
||||
const revoked = RelayDeviceRevokedMessageSchema.safeParse(message)
|
||||
if (!revoked.success) {
|
||||
return false
|
||||
}
|
||||
this.finish(reqId)
|
||||
pending.resolve(undefined)
|
||||
return true
|
||||
}
|
||||
const schema =
|
||||
pending.kind === 'install'
|
||||
? RelayDeviceCredentialInstalledMessageSchema
|
||||
: pending.kind === 'install-status'
|
||||
? RelayDeviceCredentialInstallStatusResultMessageSchema
|
||||
: RelayDeviceResumeConfirmedMessageSchema
|
||||
const result = schema.safeParse(message)
|
||||
if (!result.success) {
|
||||
return false
|
||||
}
|
||||
this.finish(reqId)
|
||||
pending.resolve(undefined)
|
||||
pending.resolve(result.data)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type WebSocket from 'ws'
|
||||
import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config'
|
||||
import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer'
|
||||
import type {
|
||||
DeviceCredentialInstalled,
|
||||
DeviceCredentialInstallStatusResult,
|
||||
DeviceResumeConfirmed,
|
||||
MobileRelayEndpoint,
|
||||
PairingProvisionRelayParams
|
||||
} from '../../../shared/mobile-relay-credential-contract'
|
||||
import type { E2EEKeypair } from '../e2ee-keypair'
|
||||
import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring'
|
||||
import { CloudRelayTransport } from '../rpc/relay-transport'
|
||||
import { RelayControlClient } from './relay-control-client'
|
||||
import type { DeviceCredentialInstallAuthorization } from './relay-control-requests'
|
||||
import {
|
||||
deriveRelayHostId,
|
||||
exchangeRelayAuthorization,
|
||||
@@ -84,6 +92,21 @@ export class RelaySessionBroker {
|
||||
return `${identity.userId}\0${identity.profileId}\0${identity.organizationId}`
|
||||
}
|
||||
|
||||
get endpoint(): MobileRelayEndpoint | null {
|
||||
const assignment = this.assignment
|
||||
if (!assignment) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
v: 1,
|
||||
directorUrl: this.options.authConfig.relayDirectorUrl,
|
||||
cellUrl: assignment.cellUrl,
|
||||
assignmentEpoch: assignment.assignmentEpoch,
|
||||
relayHostId: this.relayHostId,
|
||||
e2eeFraming: 2
|
||||
}
|
||||
}
|
||||
|
||||
createInvite(relayDeviceId: string): ReturnType<RelayControlClient['createInvite']> {
|
||||
if (!this.control) {
|
||||
return Promise.reject(new Error('relay_control_not_active'))
|
||||
@@ -117,6 +140,47 @@ export class RelaySessionBroker {
|
||||
return this.control.revokeDevice(relayDeviceId, reqId)
|
||||
}
|
||||
|
||||
async installCredential(
|
||||
relayDeviceId: string,
|
||||
params: PairingProvisionRelayParams,
|
||||
authorization: DeviceCredentialInstallAuthorization
|
||||
): Promise<DeviceCredentialInstalled> {
|
||||
if (!this.control) {
|
||||
throw new Error('relay_control_not_active')
|
||||
}
|
||||
const message = await this.control.installCredential({
|
||||
relayDeviceId,
|
||||
authorization,
|
||||
...params
|
||||
})
|
||||
this.assertCurrent()
|
||||
const { type: _type, ...result } = message
|
||||
return result
|
||||
}
|
||||
|
||||
async credentialInstallStatus(
|
||||
relayDeviceId: string,
|
||||
reqId: string
|
||||
): Promise<DeviceCredentialInstallStatusResult> {
|
||||
if (!this.control) {
|
||||
throw new Error('relay_control_not_active')
|
||||
}
|
||||
const message = await this.control.credentialInstallStatus(relayDeviceId, reqId)
|
||||
this.assertCurrent()
|
||||
const { type: _type, ...result } = message
|
||||
return result
|
||||
}
|
||||
|
||||
async confirmResume(basisConnId: string, reqId: string): Promise<DeviceResumeConfirmed> {
|
||||
if (!this.control) {
|
||||
throw new Error('relay_control_not_active')
|
||||
}
|
||||
const message = await this.control.confirmResume(basisConnId, reqId)
|
||||
this.assertCurrent()
|
||||
const { type: _type, ...result } = message
|
||||
return result
|
||||
}
|
||||
|
||||
closeNow(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
import { ZodError, type ZodType } from 'zod'
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type {
|
||||
DeviceCredentialInstalled,
|
||||
PairingGetEndpointsParams,
|
||||
PairingGetEndpointsResult,
|
||||
PairingProvisionRelayParams
|
||||
} from '../../../shared/mobile-relay-credential-contract'
|
||||
|
||||
export type PairingRpcContext = {
|
||||
getEndpoints(params: PairingGetEndpointsParams): Promise<PairingGetEndpointsResult>
|
||||
provisionRelay(params: PairingProvisionRelayParams): Promise<DeviceCredentialInstalled>
|
||||
}
|
||||
|
||||
export type RpcEnvelopeMeta = {
|
||||
runtimeId: string
|
||||
@@ -64,6 +75,7 @@ export type RpcContext = {
|
||||
// clients. Carries the paired device's scope so handlers can gate the diet to
|
||||
// phones only. Undefined for in-process callers → treat as full-class (no clip).
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
pairing?: PairingRpcContext
|
||||
// Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming
|
||||
// responses after the binary terminal cutover. Undefined on Unix/socket
|
||||
// transports and non-E2EE WebSocket paths.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isStreamingMethod,
|
||||
type RpcAnyMethod,
|
||||
type RpcEnvelopeMeta,
|
||||
type PairingRpcContext,
|
||||
type RpcRegistry,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
@@ -102,6 +103,7 @@ export class RpcDispatcher {
|
||||
signal?: AbortSignal
|
||||
clientId?: string
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
pairing?: PairingRpcContext
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
|
||||
registerBinaryStreamHandler?: (
|
||||
streamId: number,
|
||||
@@ -135,6 +137,7 @@ export class RpcDispatcher {
|
||||
connectionId: options?.connectionId,
|
||||
clientId: options?.clientId,
|
||||
clientKind: options?.clientKind,
|
||||
pairing: options?.pairing,
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ import { SKILL_METHODS } from './skills'
|
||||
import { CLIPBOARD_METHODS } from './clipboard'
|
||||
import { HOST_CAPABILITY_METHODS } from './host-capabilities'
|
||||
import { EMULATOR_METHODS } from './emulator'
|
||||
import { PAIRING_METHODS } from './pairing'
|
||||
|
||||
// Why: a flat manifest keeps registration order explicit and provides one
|
||||
// grep-point for "what methods does the RPC server expose?" — useful when
|
||||
@@ -73,5 +74,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...HOST_CAPABILITY_METHODS,
|
||||
...CLIENT_EVENT_METHODS,
|
||||
...CLIENT_UI_METHODS,
|
||||
...EMULATOR_METHODS
|
||||
...EMULATOR_METHODS,
|
||||
...PAIRING_METHODS
|
||||
]
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { PAIRING_METHODS } from './pairing'
|
||||
|
||||
function dispatchPairing(
|
||||
method: string,
|
||||
params: unknown,
|
||||
pairing: NonNullable<Parameters<RpcDispatcher['dispatchStreaming']>[2]>['pairing']
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
methods: PAIRING_METHODS
|
||||
})
|
||||
void dispatcher.dispatchStreaming(
|
||||
{ id: 'request-1', authToken: '', method, params },
|
||||
(response) => resolve(JSON.parse(response) as Record<string, unknown>),
|
||||
{ pairing }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe('pairing RPC methods', () => {
|
||||
it('passes only phone-owned credential material to the server-bound provider', async () => {
|
||||
const provisionRelay = vi.fn().mockResolvedValue({
|
||||
v: 1,
|
||||
reqId: 'install-1',
|
||||
authorizationMode: 'authenticated-direct',
|
||||
currentVersion: 1,
|
||||
resumeExpiresAt: Date.now() + 60_000
|
||||
})
|
||||
const pairing = { getEndpoints: vi.fn(), provisionRelay }
|
||||
|
||||
await expect(
|
||||
dispatchPairing(
|
||||
'pairing.provisionRelay',
|
||||
{ reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43) },
|
||||
pairing
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
expect(provisionRelay).toHaveBeenCalledWith({
|
||||
reqId: 'install-1',
|
||||
newResumeTokenHash: 'A'.repeat(43)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects caller-selected identity and authorization metadata', async () => {
|
||||
const pairing = { getEndpoints: vi.fn(), provisionRelay: vi.fn() }
|
||||
|
||||
for (const injected of [
|
||||
{ relayDeviceId: 'attacker-device' },
|
||||
{ authorization: { mode: 'relay-basis', basisConnId: 'attacker-basis' } },
|
||||
{ directAuthId: 'attacker-direct' },
|
||||
{ acceptedCredentialVersion: 99 }
|
||||
]) {
|
||||
await expect(
|
||||
dispatchPairing(
|
||||
'pairing.provisionRelay',
|
||||
{ reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43), ...injected },
|
||||
pairing
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
}
|
||||
await expect(
|
||||
dispatchPairing(
|
||||
'pairing.getEndpoints',
|
||||
{ installReqId: 'status-1', basisConnId: 'injected' },
|
||||
pairing
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(pairing.provisionRelay).not.toHaveBeenCalled()
|
||||
expect(pairing.getEndpoints).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
import {
|
||||
PairingGetEndpointsParamsSchema,
|
||||
PairingProvisionRelayParamsSchema
|
||||
} from '../../../../shared/mobile-relay-credential-contract'
|
||||
|
||||
export const PAIRING_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'pairing.getEndpoints',
|
||||
params: PairingGetEndpointsParamsSchema,
|
||||
handler: async (params, ctx) => {
|
||||
if (!ctx.pairing) {
|
||||
throw new Error('pairing_context_unavailable')
|
||||
}
|
||||
return await ctx.pairing.getEndpoints(params)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'pairing.provisionRelay',
|
||||
params: PairingProvisionRelayParamsSchema,
|
||||
handler: async (params, ctx) => {
|
||||
if (!ctx.pairing) {
|
||||
throw new Error('pairing_context_unavailable')
|
||||
}
|
||||
return await ctx.pairing.provisionRelay(params)
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -528,7 +528,9 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}),
|
||||
onDeviceRevokeQueued: vi.fn()
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
@@ -566,7 +568,9 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
})
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: vi.fn().mockRejectedValue(new Error('relay offline')),
|
||||
onDeviceRevokeQueued: vi.fn()
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
@@ -615,7 +619,9 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
}),
|
||||
onDeviceRevokeQueued: (item) => {
|
||||
registryPresence.push(server.getDeviceRegistry()?.getDevice(item.relayDeviceId) !== null)
|
||||
}
|
||||
},
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
@@ -642,6 +648,76 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('binds pairing RPC providers to the immutable authenticated socket context', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
const getEndpoints = vi.fn().mockResolvedValue({ v: 1, relay: null })
|
||||
const provisionRelay = vi.fn().mockResolvedValue({
|
||||
v: 1,
|
||||
reqId: 'install-1',
|
||||
authorizationMode: 'authenticated-direct',
|
||||
currentVersion: 1,
|
||||
resumeExpiresAt: Date.now() + 60_000
|
||||
})
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: vi.fn(),
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
getEndpoints,
|
||||
provisionRelay
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const offer = server.createPairingOffer({
|
||||
address: '127.0.0.1',
|
||||
scope: 'mobile'
|
||||
})
|
||||
expect(offer.available).toBe(true)
|
||||
if (!offer.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
const session = await authenticateMobileWsSession(offer.pairingUrl)
|
||||
const responses = createEncryptedWsResponseReader(session)
|
||||
sendEncryptedWsRequest(session, {
|
||||
id: 'endpoints-1',
|
||||
method: 'pairing.getEndpoints',
|
||||
params: { installReqId: 'status-1' }
|
||||
})
|
||||
await expect(responses.next('endpoints-1')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { v: 1, relay: null }
|
||||
})
|
||||
sendEncryptedWsRequest(session, {
|
||||
id: 'provision-1',
|
||||
method: 'pairing.provisionRelay',
|
||||
params: { reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43) }
|
||||
})
|
||||
await expect(responses.next('provision-1')).resolves.toMatchObject({ ok: true })
|
||||
|
||||
const [endpointContext, endpointParams] = getEndpoints.mock.calls[0]!
|
||||
expect(endpointContext).toEqual({
|
||||
deviceId: offer.deviceId,
|
||||
connectionId: expect.any(String),
|
||||
transport: { transport: 'direct' }
|
||||
})
|
||||
expect(endpointParams).toEqual({ installReqId: 'status-1' })
|
||||
expect(provisionRelay).toHaveBeenCalledWith(endpointContext, {
|
||||
reqId: 'install-1',
|
||||
newResumeTokenHash: 'A'.repeat(43)
|
||||
})
|
||||
responses.dispose()
|
||||
session.ws.close()
|
||||
await waitForWsClose(session.ws)
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans up pre-auth E2EE WebSocket state when the socket closes', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
|
||||
@@ -20,13 +20,23 @@ import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port-
|
||||
import type { WebSocket } from 'ws'
|
||||
import { DeviceRegistry, type DeviceScope } from './device-registry'
|
||||
import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair'
|
||||
import { MobileSocketWiring } from './rpc/mobile-socket-wiring'
|
||||
import {
|
||||
MobileSocketWiring,
|
||||
type AuthenticatedMobileSocket,
|
||||
type MobileSocketTransportMetadata
|
||||
} from './rpc/mobile-socket-wiring'
|
||||
import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer'
|
||||
import {
|
||||
RelayRevokeOutbox,
|
||||
type RelayDeviceBinding,
|
||||
type RelayRevokeOutboxItem
|
||||
} from './relay/relay-revoke-outbox'
|
||||
import type {
|
||||
DeviceCredentialInstalled,
|
||||
PairingGetEndpointsParams,
|
||||
PairingGetEndpointsResult,
|
||||
PairingProvisionRelayParams
|
||||
} from '../../shared/mobile-relay-credential-contract'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
import {
|
||||
decodeTerminalStreamFrame,
|
||||
@@ -56,8 +66,22 @@ type MobileRelayPairingProvider = {
|
||||
relayDeviceId: string
|
||||
): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }>
|
||||
onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void
|
||||
getEndpoints(
|
||||
context: MobilePairingConnectionContext,
|
||||
params: PairingGetEndpointsParams
|
||||
): Promise<PairingGetEndpointsResult>
|
||||
provisionRelay(
|
||||
context: MobilePairingConnectionContext,
|
||||
params: PairingProvisionRelayParams
|
||||
): Promise<DeviceCredentialInstalled>
|
||||
}
|
||||
|
||||
export type MobilePairingConnectionContext = Readonly<{
|
||||
deviceId: string
|
||||
connectionId: string
|
||||
transport: MobileSocketTransportMetadata
|
||||
}>
|
||||
|
||||
// Why: after 10 s of a pending dispatch we emit a tiny `{"_keepalive":true}`
|
||||
// frame every 10 s until the handler resolves. Each write resets both the
|
||||
// server's own socket idle timer (30 s) and — once §3.1 ships on the client —
|
||||
@@ -294,6 +318,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'markdown.saveTab',
|
||||
'notifications.subscribe',
|
||||
'notifications.unsubscribe',
|
||||
'pairing.getEndpoints',
|
||||
'pairing.provisionRelay',
|
||||
'preflight.check',
|
||||
'preflight.detectAgents',
|
||||
'preflight.detectRemoteAgents',
|
||||
@@ -486,6 +512,23 @@ export class OrcaRuntimeRpcServer {
|
||||
return this.relayRevokeOutbox
|
||||
}
|
||||
|
||||
setMobileRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean {
|
||||
const current = this.deviceRegistry?.getDevice(deviceId)
|
||||
if (current?.scope !== 'mobile') {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
current.relayBinding &&
|
||||
(current.relayBinding.relayHostId !== binding.relayHostId ||
|
||||
current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey)
|
||||
) {
|
||||
// Why: switching the account/host that owns this local pairing cannot
|
||||
// strand the old cloud credential family even if that account is offline.
|
||||
this.queueRelayDeviceRevoke(current.relayBinding)
|
||||
}
|
||||
return this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false
|
||||
}
|
||||
|
||||
setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void {
|
||||
this.mobileRelayPairingProvider = provider
|
||||
}
|
||||
@@ -801,7 +844,8 @@ export class OrcaRuntimeRpcServer {
|
||||
sendBinary,
|
||||
undefined,
|
||||
socket.ws,
|
||||
socket.device.deviceToken
|
||||
socket.device.deviceToken,
|
||||
socket
|
||||
)
|
||||
},
|
||||
onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws),
|
||||
@@ -958,7 +1002,8 @@ export class OrcaRuntimeRpcServer {
|
||||
sendBinary: (response: Uint8Array<ArrayBufferLike>) => boolean | void,
|
||||
wsTransport?: WebSocketTransport,
|
||||
ws?: WebSocket,
|
||||
authenticatedDeviceToken?: string | null
|
||||
authenticatedDeviceToken?: string | null,
|
||||
authenticatedSocket?: AuthenticatedMobileSocket
|
||||
): Promise<void> {
|
||||
let request: RpcRequest
|
||||
try {
|
||||
@@ -1043,6 +1088,30 @@ export class OrcaRuntimeRpcServer {
|
||||
: reply
|
||||
|
||||
const connectionId = ws ? this.mobileSocketWiring?.getConnectionId(ws) : undefined
|
||||
const pairingProvider = this.mobileRelayPairingProvider
|
||||
const pairingContext =
|
||||
pairingProvider && authenticatedSocket
|
||||
? {
|
||||
getEndpoints: (params: PairingGetEndpointsParams) =>
|
||||
pairingProvider.getEndpoints(
|
||||
{
|
||||
deviceId: authenticatedSocket.device.deviceId,
|
||||
connectionId: authenticatedSocket.connectionId,
|
||||
transport: authenticatedSocket.transport
|
||||
},
|
||||
params
|
||||
),
|
||||
provisionRelay: (params: PairingProvisionRelayParams) =>
|
||||
pairingProvider.provisionRelay(
|
||||
{
|
||||
deviceId: authenticatedSocket.device.deviceId,
|
||||
connectionId: authenticatedSocket.connectionId,
|
||||
transport: authenticatedSocket.transport
|
||||
},
|
||||
params
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
try {
|
||||
await this.dispatcher.dispatchStreaming(request, replyForRequest, {
|
||||
connectionId,
|
||||
@@ -1050,6 +1119,7 @@ export class OrcaRuntimeRpcServer {
|
||||
// Why: gates the mobile-only payload diet (native-chat char clipping) so
|
||||
// full-screen web/desktop runtime clients aren't truncated.
|
||||
clientKind: device.scope,
|
||||
pairing: pairingContext,
|
||||
signal: abortRegistration?.signal,
|
||||
sendBinary,
|
||||
registerBinaryStreamHandler: (streamId, handler) =>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const OpaqueIdSchema = z.string().min(1).max(128)
|
||||
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
|
||||
const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
const RelayHostIdSchema = z.string().regex(/^[A-Za-z0-9_-]{16}$/)
|
||||
|
||||
function isCanonicalHttpsOrigin(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
return parsed.protocol === 'https:' && value === parsed.origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const PairingProvisionRelayParamsSchema = z
|
||||
.object({
|
||||
reqId: OpaqueIdSchema,
|
||||
newResumeTokenHash: Base64Url32ByteSchema,
|
||||
expectedCurrentHash: Base64Url32ByteSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const PairingGetEndpointsParamsSchema = z
|
||||
.object({
|
||||
installReqId: OpaqueIdSchema.optional(),
|
||||
resumeConfirmReqId: OpaqueIdSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const DeviceCredentialInstalledSchema = z
|
||||
.object({
|
||||
v: z.literal(1),
|
||||
reqId: OpaqueIdSchema,
|
||||
authorizationMode: z.enum(['relay-basis', 'authenticated-direct']),
|
||||
currentVersion: z.number().int().positive(),
|
||||
resumeExpiresAt: EpochMsSchema,
|
||||
graceExpiresAt: EpochMsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const DeviceCredentialInstallStatusResultSchema = z.union([
|
||||
z.object({ v: z.literal(1), reqId: OpaqueIdSchema, state: z.literal('not-found') }).strict(),
|
||||
z
|
||||
.object({
|
||||
v: z.literal(1),
|
||||
reqId: OpaqueIdSchema,
|
||||
state: z.literal('committed'),
|
||||
result: DeviceCredentialInstalledSchema
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
export const DeviceResumeConfirmedSchema = z
|
||||
.object({
|
||||
v: z.literal(1),
|
||||
reqId: OpaqueIdSchema,
|
||||
currentVersion: z.number().int().positive(),
|
||||
acceptedAs: z.enum(['current', 'grace']),
|
||||
renewed: z.boolean(),
|
||||
resumeExpiresAt: EpochMsSchema,
|
||||
graceExpiresAt: EpochMsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileRelayEndpointSchema = z
|
||||
.object({
|
||||
v: z.literal(1),
|
||||
directorUrl: z.string().refine(isCanonicalHttpsOrigin),
|
||||
cellUrl: z.string().refine(isCanonicalHttpsOrigin),
|
||||
assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
relayHostId: RelayHostIdSchema,
|
||||
e2eeFraming: z.literal(2)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const PairingGetEndpointsResultSchema = z
|
||||
.object({
|
||||
v: z.literal(1),
|
||||
relay: MobileRelayEndpointSchema.nullable(),
|
||||
installStatus: DeviceCredentialInstallStatusResultSchema.optional(),
|
||||
resumeConfirmation: DeviceResumeConfirmedSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type PairingProvisionRelayParams = z.infer<typeof PairingProvisionRelayParamsSchema>
|
||||
export type PairingGetEndpointsParams = z.infer<typeof PairingGetEndpointsParamsSchema>
|
||||
export type DeviceCredentialInstalled = z.infer<typeof DeviceCredentialInstalledSchema>
|
||||
export type DeviceCredentialInstallStatusResult = z.infer<
|
||||
typeof DeviceCredentialInstallStatusResultSchema
|
||||
>
|
||||
export type DeviceResumeConfirmed = z.infer<typeof DeviceResumeConfirmedSchema>
|
||||
export type MobileRelayEndpoint = z.infer<typeof MobileRelayEndpointSchema>
|
||||
export type PairingGetEndpointsResult = z.infer<typeof PairingGetEndpointsResultSchema>
|
||||
Reference in New Issue
Block a user