dev/2026-04-08 #1
172
plugin/core/state.ts
Normal file
172
plugin/core/state.ts
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
export { validateYonexusClientConfig, YonexusClientConfigError } from "./core/config.js";
|
export { validateYonexusClientConfig, YonexusClientConfigError } from "./core/config.js";
|
||||||
export type { YonexusClientConfig } 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 {
|
export interface YonexusClientPluginManifest {
|
||||||
readonly name: "Yonexus.Client";
|
readonly name: "Yonexus.Client";
|
||||||
|
|||||||
Reference in New Issue
Block a user