feat: handle client pairing messages
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
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";
|
||||
@@ -39,6 +43,12 @@ 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 {
|
||||
@@ -46,6 +56,12 @@ export class YonexusClientRuntime {
|
||||
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;
|
||||
@@ -57,7 +73,9 @@ export class YonexusClientRuntime {
|
||||
return {
|
||||
phase: this.phase,
|
||||
transportState: this.options.transport.state,
|
||||
clientState: this.clientState
|
||||
clientState: this.clientState,
|
||||
pendingPairing: this.pendingPairing,
|
||||
lastPairingFailure: this.lastPairingFailure
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +118,21 @@ export class YonexusClientRuntime {
|
||||
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;
|
||||
@@ -134,6 +167,26 @@ export class YonexusClientRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -155,6 +208,55 @@ export class YonexusClientRuntime {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user