From 2148027a411a6545aa0b36b9d15494af7bb750a1 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 20:33:25 +0000 Subject: [PATCH] feat(client): add local trust material state store --- plugin/core/state.ts | 172 +++++++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 14 ++++ 2 files changed, 186 insertions(+) create mode 100644 plugin/core/state.ts diff --git a/plugin/core/state.ts b/plugin/core/state.ts new file mode 100644 index 0000000..fa77aca --- /dev/null +++ b/plugin/core/state.ts @@ -0,0 +1,172 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +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 + ); +} + +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}` + ); + } + + if (!Number.isInteger(candidate.updatedAt) || candidate.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" + ); +} diff --git a/plugin/index.ts b/plugin/index.ts index 28b2420..502d7ff 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,5 +1,19 @@ export { validateYonexusClientConfig, YonexusClientConfigError } from "./core/config.js"; export type { YonexusClientConfig } from "./core/config.js"; +export { + CLIENT_STATE_VERSION, + YonexusClientStateError, + YonexusClientStateCorruptionError, + createYonexusClientStateStore, + loadYonexusClientState, + saveYonexusClientState, + createInitialClientState, + hasClientSecret, + hasClientKeyPair, + type YonexusClientState, + type YonexusClientStateFile, + type YonexusClientStateStore +} from "./core/state.js"; export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client";