/** * 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 /** Public key presented during hello, before pairing completes */ publicKey?: string; /** 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; }