feat(client): add local trust material state store

This commit is contained in:
nav
2026-04-08 20:33:25 +00:00
parent 1d751b7c55
commit 2148027a41
2 changed files with 186 additions and 0 deletions

172
plugin/core/state.ts Normal file
View File

@@ -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<YonexusClientState>;
save(state: YonexusClientState): Promise<void>;
}
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<YonexusClientState> {
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<void> {
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<YonexusClientStateFile>;
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"
);
}

View File

@@ -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";