Files
Yonexus.Client/plugin/core/runtime.ts

340 lines
9.2 KiB
TypeScript

import {
YONEXUS_PROTOCOL_VERSION,
buildAuthRequest,
buildHello,
buildPairConfirm,
createAuthRequestSigningInput,
decodeBuiltin,
encodeBuiltin,
isBuiltinMessage,
type AuthFailedPayload,
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 { generateNonce, signMessage } from "../crypto/keypair.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<void> {
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<void> {
await this.options.stateStore.save({
...this.clientState,
updatedAt: this.now()
});
this.options.transport.disconnect();
this.phase = "stopped";
}
async handleMessage(raw: string): Promise<void> {
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.options.transport.markAuthenticated();
this.clientState = {
...this.clientState,
authenticatedAt: this.now(),
updatedAt: this.now()
};
await this.options.stateStore.save(this.clientState);
this.phase = "authenticated";
return;
}
if (envelope.type === "auth_failed") {
this.handleAuthFailed(envelope as TypedBuiltinEnvelope<"auth_failed">);
return;
}
if (envelope.type === "re_pair_required") {
this.pendingPairing = undefined;
this.lastPairingFailure = "re_pair_required";
this.phase = "pair_required";
return;
}
}
handleTransportStateChange(state: ClientConnectionState): void {
if (state === "connected") {
this.sendHello();
}
if (state === "disconnected") {
this.phase = hasClientSecret(this.clientState) ? "auth_required" : "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";
void this.sendAuthRequest();
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<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";
await this.sendAuthRequest();
}
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";
}
private handleAuthFailed(envelope: TypedBuiltinEnvelope<"auth_failed">): void {
const payload = envelope.payload as AuthFailedPayload | undefined;
if (!payload) {
return;
}
this.lastPairingFailure = payload.reason;
this.phase = payload.reason === "re_pair_required" ? "pair_required" : "auth_required";
}
private async sendAuthRequest(): Promise<void> {
if (!this.options.transport.isConnected) {
return;
}
if (!this.clientState.secret || !this.clientState.privateKey) {
this.phase = "pair_required";
return;
}
const proofTimestamp = this.now();
const nonce = generateNonce();
const signature = await signMessage(
this.clientState.privateKey,
createAuthRequestSigningInput({
secret: this.clientState.secret,
nonce,
proofTimestamp
})
);
this.options.transport.markAuthenticating();
this.options.transport.send(
encodeBuiltin(
buildAuthRequest(
{
identifier: this.options.config.identifier,
nonce,
proofTimestamp,
signature,
publicKey: this.clientState.publicKey
},
{ requestId: `auth_${proofTimestamp}_${nonce}`, timestamp: proofTimestamp }
)
)
);
}
}
export function createYonexusClientRuntime(
options: YonexusClientRuntimeOptions
): YonexusClientRuntime {
return new YonexusClientRuntime(options);
}