259 lines
6.8 KiB
TypeScript
259 lines
6.8 KiB
TypeScript
/**
|
|
* 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;
|
|
|
|
/** Last successful pairing timestamp (UTC unix seconds) */
|
|
pairedAt?: 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<string, ClientRecord>;
|
|
|
|
/** Active WebSocket sessions keyed by identifier */
|
|
sessions: Map<string, ClientSession>;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
pairedAt?: 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,
|
|
pairedAt: record.pairedAt,
|
|
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;
|
|
}
|