From bc1a002a8cf84df0273e4bd691913e460ed100db Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 20:20:11 +0000 Subject: [PATCH] feat(server): add persistence types and ClientRecord structure - Add ClientRecord, ClientSession, ServerRegistry interfaces - Add serialization helpers for persistent storage - Add state check functions (isPairable, canAuthenticate, etc.) - Export persistence types from plugin index.ts --- plugin/core/persistence.ts | 250 +++++++++++++++++++++++++++++++++++++ plugin/index.ts | 19 +++ 2 files changed, 269 insertions(+) create mode 100644 plugin/core/persistence.ts diff --git a/plugin/core/persistence.ts b/plugin/core/persistence.ts new file mode 100644 index 0000000..5cbbeeb --- /dev/null +++ b/plugin/core/persistence.ts @@ -0,0 +1,250 @@ +/** + * Yonexus Server - Persistence Types + * + * Defines the persistent record structures for client registry and state management. + * Based on PLAN.md section 6 and 12. + */ + +/** + * Client pairing status + */ +export type PairingStatus = "unpaired" | "pending" | "paired" | "revoked"; + +/** + * Client liveness status + */ +export type ClientLivenessStatus = "online" | "offline" | "unstable"; + +/** + * Pairing notification delivery status + */ +export type PairingNotifyStatus = "pending" | "sent" | "failed"; + +/** + * Security window entry for nonce tracking + */ +export interface NonceEntry { + /** The nonce value */ + readonly nonce: string; + /** UTC unix timestamp when the nonce was used */ + readonly timestamp: number; +} + +/** + * Security window entry for handshake attempt tracking + */ +export interface HandshakeAttemptEntry { + /** UTC unix timestamp of the attempt */ + readonly timestamp: number; +} + +/** + * Persistent client record stored by Yonexus.Server + * + * This structure represents the durable trust state for a client. + * Rolling security windows (recentNonces, recentHandshakeAttempts) may be + * cleared on server restart as per v1 semantics. + */ +export interface ClientRecord { + /** Unique client identifier */ + readonly identifier: string; + + /** Client's public key (Ed25519 or other) - stored after pairing */ + publicKey?: string; + + /** Shared secret issued after successful pairing */ + secret?: string; + + /** Current pairing status */ + pairingStatus: PairingStatus; + + /** Pairing code (only valid when pairingStatus is "pending") */ + pairingCode?: string; + + /** Pairing expiration timestamp (UTC unix seconds) */ + pairingExpiresAt?: number; + + /** When the pairing notification was sent (UTC unix seconds) */ + pairingNotifiedAt?: number; + + /** Status of the pairing notification delivery */ + pairingNotifyStatus?: PairingNotifyStatus; + + /** Current liveness status (may be stale on restart) */ + status: ClientLivenessStatus; + + /** Last heartbeat received timestamp (UTC unix seconds) */ + lastHeartbeatAt?: number; + + /** Last successful authentication timestamp (UTC unix seconds) */ + lastAuthenticatedAt?: number; + + /** + * Recent nonces used in authentication attempts. + * This is a rolling window that may be cleared on restart. + */ + recentNonces: NonceEntry[]; + + /** + * Recent handshake attempt timestamps. + * This is a rolling window that may be cleared on restart. + */ + recentHandshakeAttempts: number[]; + + /** Record creation timestamp (UTC unix seconds) */ + readonly createdAt: number; + + /** Record last update timestamp (UTC unix seconds) */ + updatedAt: number; +} + +/** + * In-memory session state (not persisted) + * + * Represents an active or pending WebSocket connection. + */ +export interface ClientSession { + /** Client identifier */ + readonly identifier: string; + + /** WebSocket connection instance */ + readonly socket: unknown; // Will be typed as WebSocket when implementing transport + + /** Whether the client is currently authenticated */ + isAuthenticated: boolean; + + /** Session start timestamp (UTC unix seconds) */ + readonly connectedAt: number; + + /** Last activity timestamp (UTC unix seconds) */ + lastActivityAt: number; +} + +/** + * Server registry state + * + * Contains both persistent and in-memory state for all clients. + */ +export interface ServerRegistry { + /** Persistent client records keyed by identifier */ + clients: Map; + + /** Active WebSocket sessions keyed by identifier */ + sessions: Map; +} + +/** + * Serialized form of ClientRecord for JSON persistence + */ +export interface SerializedClientRecord { + identifier: string; + publicKey?: string; + secret?: string; + pairingStatus: PairingStatus; + pairingCode?: string; + pairingExpiresAt?: number; + pairingNotifiedAt?: number; + pairingNotifyStatus?: PairingNotifyStatus; + status: ClientLivenessStatus; + lastHeartbeatAt?: number; + lastAuthenticatedAt?: number; + createdAt: number; + updatedAt: number; + // Note: recentNonces and recentHandshakeAttempts are intentionally + // excluded from persistent serialization - they are cleared on restart +} + +/** + * Server persistence file format + */ +export interface ServerPersistenceData { + /** Format version for migration support */ + version: number; + + /** Server-side client records */ + clients: SerializedClientRecord[]; + + /** Persistence timestamp (UTC unix seconds) */ + persistedAt: number; +} + +/** + * Create a new empty client record + */ +export function createClientRecord(identifier: string): ClientRecord { + const now = Math.floor(Date.now() / 1000); + return { + identifier, + pairingStatus: "unpaired", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now, + updatedAt: now + }; +} + +/** + * Convert a ClientRecord to its serialized form (for persistence) + */ +export function serializeClientRecord(record: ClientRecord): SerializedClientRecord { + return { + identifier: record.identifier, + publicKey: record.publicKey, + secret: record.secret, + pairingStatus: record.pairingStatus, + pairingCode: record.pairingCode, + pairingExpiresAt: record.pairingExpiresAt, + pairingNotifiedAt: record.pairingNotifiedAt, + pairingNotifyStatus: record.pairingNotifyStatus, + status: record.status, + lastHeartbeatAt: record.lastHeartbeatAt, + lastAuthenticatedAt: record.lastAuthenticatedAt, + createdAt: record.createdAt, + updatedAt: record.updatedAt + }; +} + +/** + * Deserialize a client record and initialize rolling windows + */ +export function deserializeClientRecord( + serialized: SerializedClientRecord +): ClientRecord { + return { + ...serialized, + recentNonces: [], // Rolling windows cleared on restart + recentHandshakeAttempts: [] + }; +} + +/** + * Check if a client record is in a pairable state + */ +export function isPairable(record: ClientRecord): boolean { + return record.pairingStatus === "unpaired" || record.pairingStatus === "revoked"; +} + +/** + * Check if a client record has a pending pairing that may have expired + */ +export function hasPendingPairing(record: ClientRecord): boolean { + return record.pairingStatus === "pending" && record.pairingCode !== undefined; +} + +/** + * Check if a pending pairing has expired + */ +export function isPairingExpired(record: ClientRecord, now: number = Date.now() / 1000): boolean { + if (!hasPendingPairing(record) || record.pairingExpiresAt === undefined) { + return false; + } + return now > record.pairingExpiresAt; +} + +/** + * Check if a client is ready for authentication (has secret and is paired) + */ +export function canAuthenticate(record: ClientRecord): boolean { + return record.pairingStatus === "paired" && record.secret !== undefined && record.publicKey !== undefined; +} diff --git a/plugin/index.ts b/plugin/index.ts index 476eca3..a02ec78 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,5 +1,24 @@ export { validateYonexusServerConfig, YonexusServerConfigError } from "./core/config.js"; export type { YonexusServerConfig } from "./core/config.js"; +export { + createClientRecord, + serializeClientRecord, + deserializeClientRecord, + isPairable, + hasPendingPairing, + isPairingExpired, + canAuthenticate, + type PairingStatus, + type ClientLivenessStatus, + type PairingNotifyStatus, + type NonceEntry, + type HandshakeAttemptEntry, + type ClientRecord, + type ClientSession, + type ServerRegistry, + type SerializedClientRecord, + type ServerPersistenceData +} from "./core/persistence.js"; export interface YonexusServerPluginManifest { readonly name: "Yonexus.Server";