import { YONEXUS_PROTOCOL_VERSION, buildHello, buildPairConfirm, decodeBuiltin, encodeBuiltin, isBuiltinMessage, type HelloAckPayload, type PairFailedPayload, type PairRequestPayload, type PairSuccessPayload, type TypedBuiltinEnvelope } from "../../../Yonexus.Protocol/src/index.js"; import type { YonexusClientConfig } from "./config.js"; import { ensureClientKeyPair, hasClientKeyPair, hasClientSecret, createInitialClientState, YonexusClientState, YonexusClientStateStore } from "./state.js"; import type { ClientConnectionState, ClientTransport } from "./transport.js"; export type YonexusClientPhase = | "idle" | "starting" | "awaiting_hello_ack" | "pair_required" | "waiting_pair_confirm" | "auth_required" | "authenticated" | "stopped"; export interface YonexusClientRuntimeOptions { config: YonexusClientConfig; transport: ClientTransport; stateStore: YonexusClientStateStore; now?: () => number; } export interface YonexusClientRuntimeState { readonly phase: YonexusClientPhase; readonly transportState: ClientConnectionState; readonly clientState: YonexusClientState; readonly pendingPairing?: { expiresAt: number; ttlSeconds: number; adminNotification: "sent" | "failed"; }; readonly lastPairingFailure?: string; } export class YonexusClientRuntime { private readonly options: YonexusClientRuntimeOptions; private readonly now: () => number; private clientState: YonexusClientState; private phase: YonexusClientPhase = "idle"; private pendingPairing?: { expiresAt: number; ttlSeconds: number; adminNotification: "sent" | "failed"; }; private lastPairingFailure?: string; constructor(options: YonexusClientRuntimeOptions) { this.options = options; this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); this.clientState = createInitialClientState(options.config.identifier); } get state(): YonexusClientRuntimeState { return { phase: this.phase, transportState: this.options.transport.state, clientState: this.clientState, pendingPairing: this.pendingPairing, lastPairingFailure: this.lastPairingFailure }; } async start(): Promise { if (this.phase !== "idle" && this.phase !== "stopped") { return; } this.phase = "starting"; // Load existing state and ensure key pair exists let state = await this.options.stateStore.load(this.options.config.identifier); const keyResult = await ensureClientKeyPair(state, this.options.stateStore); this.clientState = keyResult.state; await this.options.transport.connect(); } async stop(): Promise { await this.options.stateStore.save({ ...this.clientState, updatedAt: this.now() }); this.options.transport.disconnect(); this.phase = "stopped"; } async handleMessage(raw: string): Promise { if (raw === "heartbeat_tick") { return; } if (!isBuiltinMessage(raw)) { return; } const envelope = decodeBuiltin(raw); if (envelope.type === "hello_ack") { this.handleHelloAck(envelope as TypedBuiltinEnvelope<"hello_ack">); return; } if (envelope.type === "pair_request") { this.handlePairRequest(envelope as TypedBuiltinEnvelope<"pair_request">); return; } if (envelope.type === "pair_success") { await this.handlePairSuccess(envelope as TypedBuiltinEnvelope<"pair_success">); return; } if (envelope.type === "pair_failed") { this.handlePairFailed(envelope as TypedBuiltinEnvelope<"pair_failed">); return; } if (envelope.type === "auth_success") { this.phase = "authenticated"; return; } } handleTransportStateChange(state: ClientConnectionState): void { if (state === "connected") { this.sendHello(); } if (state === "disconnected") { this.phase = "idle"; } } private sendHello(): void { this.phase = "awaiting_hello_ack"; this.options.transport.send( encodeBuiltin( buildHello( { identifier: this.options.config.identifier, hasSecret: hasClientSecret(this.clientState), hasKeyPair: hasClientKeyPair(this.clientState), publicKey: this.clientState.publicKey, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: this.now() } ) ) ); } submitPairingCode(pairingCode: string, requestId?: string): boolean { const normalizedCode = pairingCode.trim(); if (!normalizedCode || !this.options.transport.isConnected) { return false; } this.lastPairingFailure = undefined; return this.options.transport.send( encodeBuiltin( buildPairConfirm( { identifier: this.options.config.identifier, pairingCode: normalizedCode }, { requestId, timestamp: this.now() } ) ) ); } private handleHelloAck(envelope: TypedBuiltinEnvelope<"hello_ack">): void { const payload = envelope.payload as HelloAckPayload | undefined; if (!payload) { return; } switch (payload.nextAction) { case "pair_required": this.phase = "pair_required"; break; case "waiting_pair_confirm": this.phase = "waiting_pair_confirm"; break; case "auth_required": this.phase = "auth_required"; break; default: this.phase = "idle"; break; } } private handlePairRequest(envelope: TypedBuiltinEnvelope<"pair_request">): void { const payload = envelope.payload as PairRequestPayload | undefined; if (!payload) { return; } this.pendingPairing = { expiresAt: payload.expiresAt, ttlSeconds: payload.ttlSeconds, adminNotification: payload.adminNotification }; this.lastPairingFailure = undefined; this.phase = payload.adminNotification === "sent" ? "waiting_pair_confirm" : "pair_required"; } private async handlePairSuccess(envelope: TypedBuiltinEnvelope<"pair_success">): Promise { const payload = envelope.payload as PairSuccessPayload | undefined; if (!payload) { return; } this.clientState = { ...this.clientState, secret: payload.secret, pairedAt: payload.pairedAt, updatedAt: this.now() }; await this.options.stateStore.save(this.clientState); this.pendingPairing = undefined; this.lastPairingFailure = undefined; this.phase = "auth_required"; } private handlePairFailed(envelope: TypedBuiltinEnvelope<"pair_failed">): void { const payload = envelope.payload as PairFailedPayload | undefined; if (!payload) { return; } this.lastPairingFailure = payload.reason; if (payload.reason === "expired" || payload.reason === "admin_notification_failed") { this.pendingPairing = undefined; this.phase = "pair_required"; return; } this.phase = "waiting_pair_confirm"; } } export function createYonexusClientRuntime( options: YonexusClientRuntimeOptions ): YonexusClientRuntime { return new YonexusClientRuntime(options); }