import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { generateKeyPair, type KeyPair } from "../crypto/keypair.js"; export const CLIENT_STATE_VERSION = 1; export interface YonexusClientState { identifier: string; privateKey?: string; secret?: string; publicKey?: string; pairedAt?: number; authenticatedAt?: number; updatedAt: number; } export interface YonexusClientStateFile extends YonexusClientState { version: number; } export class YonexusClientStateError extends Error { override readonly cause?: unknown; constructor(message: string, cause?: unknown) { super(message); this.name = "YonexusClientStateError"; this.cause = cause; } } export class YonexusClientStateCorruptionError extends YonexusClientStateError { constructor(message: string, cause?: unknown) { super(message, cause); this.name = "YonexusClientStateCorruptionError"; } } export interface YonexusClientStateStore { readonly filePath: string; load(identifier: string): Promise; save(state: YonexusClientState): Promise; } export function createYonexusClientStateStore(filePath: string): YonexusClientStateStore { return { filePath, load: async (identifier) => loadYonexusClientState(filePath, identifier), save: async (state) => saveYonexusClientState(filePath, state) }; } export async function loadYonexusClientState( filePath: string, identifier: string ): Promise { try { const raw = await readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as YonexusClientStateFile; assertClientStateShape(parsed, filePath); return { identifier: parsed.identifier, privateKey: parsed.privateKey, publicKey: parsed.publicKey, secret: parsed.secret, pairedAt: parsed.pairedAt, authenticatedAt: parsed.authenticatedAt, updatedAt: parsed.updatedAt }; } catch (error) { if (isFileNotFoundError(error)) { return createInitialClientState(identifier); } if (error instanceof YonexusClientStateError) { throw error; } throw new YonexusClientStateCorruptionError( `Failed to load Yonexus.Client state file: ${filePath}`, error ); } } export async function saveYonexusClientState( filePath: string, state: YonexusClientState ): Promise { const normalizedState = { ...state, identifier: state.identifier.trim(), updatedAt: state.updatedAt || Math.floor(Date.now() / 1000) } satisfies YonexusClientState; const payload: YonexusClientStateFile = { version: CLIENT_STATE_VERSION, ...normalizedState }; const tempPath = `${filePath}.tmp`; try { await mkdir(dirname(filePath), { recursive: true }); await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); await rename(tempPath, filePath); } catch (error) { throw new YonexusClientStateError( `Failed to save Yonexus.Client state file: ${filePath}`, error ); } } export function createInitialClientState(identifier: string): YonexusClientState { const normalizedIdentifier = identifier.trim(); return { identifier: normalizedIdentifier, updatedAt: Math.floor(Date.now() / 1000) }; } export function hasClientSecret(state: YonexusClientState): boolean { return typeof state.secret === "string" && state.secret.length > 0; } export function hasClientKeyPair(state: YonexusClientState): boolean { return ( typeof state.privateKey === "string" && state.privateKey.length > 0 && typeof state.publicKey === "string" && state.publicKey.length > 0 ); } /** * Ensure the client state has a valid key pair. * If no key pair exists, generates a new Ed25519 key pair. * Returns the (possibly updated) state and whether a new key was generated. */ export async function ensureClientKeyPair( state: YonexusClientState, stateStore: YonexusClientStateStore ): Promise<{ state: YonexusClientState; generated: boolean }> { if (hasClientKeyPair(state)) { return { state, generated: false }; } const keyPair = await generateKeyPair(); const updatedState: YonexusClientState = { ...state, privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, updatedAt: Math.floor(Date.now() / 1000) }; await stateStore.save(updatedState); return { state: updatedState, generated: true }; } function assertClientStateShape( value: unknown, filePath: string ): asserts value is YonexusClientStateFile { if (!value || typeof value !== "object") { throw new YonexusClientStateCorruptionError( `State file is not a JSON object: ${filePath}` ); } const candidate = value as Partial; if (candidate.version !== CLIENT_STATE_VERSION) { throw new YonexusClientStateCorruptionError( `Unsupported client state version in ${filePath}: ${String(candidate.version)}` ); } if (typeof candidate.identifier !== "string" || candidate.identifier.trim().length === 0) { throw new YonexusClientStateCorruptionError( `Client state file has invalid identifier: ${filePath}` ); } const updatedAt = candidate.updatedAt; if (typeof updatedAt !== "number" || !Number.isInteger(updatedAt) || updatedAt < 0) { throw new YonexusClientStateCorruptionError( `Client state file has invalid updatedAt value: ${filePath}` ); } } function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { return ( typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT" ); }