From ac128d3827c42c88adf5f97e7ea7002423c5b30f Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 19:33:32 +0000 Subject: [PATCH 01/29] feat: scaffold yonexus server plugin --- package.json | 27 +++++++++++++++++++++++++++ plugin/commands/.gitkeep | 0 plugin/core/.gitkeep | 0 plugin/hooks/.gitkeep | 0 plugin/index.ts | 28 ++++++++++++++++++++++++++++ plugin/openclaw.plugin.json | 15 +++++++++++++++ plugin/tools/.gitkeep | 0 scripts/install.mjs | 37 +++++++++++++++++++++++++++++++++++++ servers/.gitkeep | 0 skills/.gitkeep | 0 tsconfig.json | 24 ++++++++++++++++++++++++ 11 files changed, 131 insertions(+) create mode 100644 package.json create mode 100644 plugin/commands/.gitkeep create mode 100644 plugin/core/.gitkeep create mode 100644 plugin/hooks/.gitkeep create mode 100644 plugin/tools/.gitkeep create mode 100644 servers/.gitkeep create mode 100644 skills/.gitkeep create mode 100644 tsconfig.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..20dc0cc --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "yonexus-server", + "version": "0.1.0", + "private": true, + "description": "Yonexus.Server OpenClaw plugin scaffold", + "type": "module", + "main": "dist/plugin/index.js", + "files": [ + "dist", + "plugin", + "scripts", + "protocol", + "README.md", + "PLAN.md", + "SCAFFOLD.md", + "STRUCTURE.md", + "TASKS.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist", + "check": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "typescript": "^5.6.3" + } +} diff --git a/plugin/commands/.gitkeep b/plugin/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/core/.gitkeep b/plugin/core/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/hooks/.gitkeep b/plugin/hooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/index.ts b/plugin/index.ts index e69de29..4da1049 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -0,0 +1,28 @@ +export interface YonexusServerPluginManifest { + readonly name: "Yonexus.Server"; + readonly version: string; + readonly description: string; +} + +export interface YonexusServerPluginRuntime { + readonly hooks: readonly []; + readonly commands: readonly []; + readonly tools: readonly []; +} + +const manifest: YonexusServerPluginManifest = { + name: "Yonexus.Server", + version: "0.1.0", + description: "Yonexus central hub plugin for cross-instance OpenClaw communication" +}; + +export function createYonexusServerPlugin(): YonexusServerPluginRuntime { + return { + hooks: [], + commands: [], + tools: [] + }; +} + +export default createYonexusServerPlugin; +export { manifest }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index e69de29..def1da4 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -0,0 +1,15 @@ +{ + "name": "Yonexus.Server", + "version": "0.1.0", + "description": "Yonexus central hub plugin for cross-instance OpenClaw communication", + "entry": "dist/plugin/index.js", + "permissions": [], + "config": { + "followerIdentifiers": [], + "notifyBotToken": "", + "adminUserId": "", + "listenHost": "0.0.0.0", + "listenPort": 8787, + "publicWsUrl": "" + } +} diff --git a/plugin/tools/.gitkeep b/plugin/tools/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/install.mjs b/scripts/install.mjs index e69de29..ff3f7dd 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const args = process.argv.slice(2); +const mode = args.includes("--install") ? "install" : args.includes("--uninstall") ? "uninstall" : null; +const profileIndex = args.indexOf("--openclaw-profile-path"); +const profilePath = profileIndex >= 0 ? args[profileIndex + 1] : path.join(os.homedir(), ".openclaw"); + +if (!mode) { + console.error("Usage: node scripts/install.mjs --install|--uninstall [--openclaw-profile-path ]"); + process.exit(1); +} + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const pluginName = "Yonexus.Server"; +const sourceDist = path.join(repoRoot, "dist"); +const targetDir = path.join(profilePath, "plugins", pluginName); + +if (mode === "install") { + if (!fs.existsSync(sourceDist)) { + console.error(`Build output not found: ${sourceDist}`); + process.exit(1); + } + + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.cpSync(sourceDist, path.join(targetDir, "dist"), { recursive: true }); + fs.copyFileSync(path.join(repoRoot, "plugin", "openclaw.plugin.json"), path.join(targetDir, "openclaw.plugin.json")); + console.log(`Installed ${pluginName} to ${targetDir}`); + process.exit(0); +} + +fs.rmSync(targetDir, { recursive: true, force: true }); +console.log(`Removed ${pluginName} from ${targetDir}`); diff --git a/servers/.gitkeep b/servers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/skills/.gitkeep b/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cf17390 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true + }, + "include": [ + "plugin/**/*.ts", + "servers/**/*.ts" + ], + "exclude": [ + "dist", + "node_modules" + ] +} From 3ec57ce19910aefcf04531912c11fc0d20887a5a Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 20:03:28 +0000 Subject: [PATCH 02/29] feat: add server config validation --- plugin/core/config.ts | 104 ++++++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 3 ++ 2 files changed, 107 insertions(+) create mode 100644 plugin/core/config.ts diff --git a/plugin/core/config.ts b/plugin/core/config.ts new file mode 100644 index 0000000..7f352ca --- /dev/null +++ b/plugin/core/config.ts @@ -0,0 +1,104 @@ +export interface YonexusServerConfig { + followerIdentifiers: string[]; + notifyBotToken: string; + adminUserId: string; + listenHost?: string; + listenPort: number; + publicWsUrl?: string; +} + +export class YonexusServerConfigError extends Error { + readonly issues: string[]; + + constructor(issues: string[]) { + super(`Invalid Yonexus.Server config: ${issues.join("; ")}`); + this.name = "YonexusServerConfigError"; + this.issues = issues; + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function normalizeOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isValidPort(value: unknown): value is number { + return Number.isInteger(value) && value >= 1 && value <= 65535; +} + +function isValidWsUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "ws:" || url.protocol === "wss:"; + } catch { + return false; + } +} + +export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig { + const source = raw as Record | null; + const issues: string[] = []; + + const rawIdentifiers = source?.followerIdentifiers; + const followerIdentifiers = Array.isArray(rawIdentifiers) + ? rawIdentifiers + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + : []; + + if (!Array.isArray(rawIdentifiers) || followerIdentifiers.length === 0) { + issues.push("followerIdentifiers must contain at least one non-empty identifier"); + } + + if (new Set(followerIdentifiers).size !== followerIdentifiers.length) { + issues.push("followerIdentifiers must not contain duplicates"); + } + + const notifyBotToken = source?.notifyBotToken; + if (!isNonEmptyString(notifyBotToken)) { + issues.push("notifyBotToken is required"); + } + + const adminUserId = source?.adminUserId; + if (!isNonEmptyString(adminUserId)) { + issues.push("adminUserId is required"); + } + + const listenPort = source?.listenPort; + if (!isValidPort(listenPort)) { + issues.push("listenPort must be an integer between 1 and 65535"); + } + + const listenHost = normalizeOptionalString(source?.listenHost) ?? "0.0.0.0"; + const publicWsUrl = normalizeOptionalString(source?.publicWsUrl); + + if (publicWsUrl !== undefined && !isValidWsUrl(publicWsUrl)) { + issues.push("publicWsUrl must be a valid ws:// or wss:// URL when provided"); + } + + if (issues.length > 0) { + throw new YonexusServerConfigError(issues); + } + + return { + followerIdentifiers, + notifyBotToken: notifyBotToken.trim(), + adminUserId: adminUserId.trim(), + listenHost, + listenPort, + publicWsUrl + }; +} diff --git a/plugin/index.ts b/plugin/index.ts index 4da1049..476eca3 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,3 +1,6 @@ +export { validateYonexusServerConfig, YonexusServerConfigError } from "./core/config.js"; +export type { YonexusServerConfig } from "./core/config.js"; + export interface YonexusServerPluginManifest { readonly name: "Yonexus.Server"; readonly version: string; From bc1a002a8cf84df0273e4bd691913e460ed100db Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 20:20:11 +0000 Subject: [PATCH 03/29] 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"; From c5287fa474644cec72d1e863e36a34b50977d929 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 20:33:25 +0000 Subject: [PATCH 04/29] feat(server): add registry persistence store --- plugin/core/store.ts | 181 +++++++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 10 +++ 2 files changed, 191 insertions(+) create mode 100644 plugin/core/store.ts diff --git a/plugin/core/store.ts b/plugin/core/store.ts new file mode 100644 index 0000000..18e1337 --- /dev/null +++ b/plugin/core/store.ts @@ -0,0 +1,181 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { + deserializeClientRecord, + serializeClientRecord, + type ClientRecord, + type SerializedClientRecord, + type ServerPersistenceData +} from "./persistence.js"; + +export const SERVER_PERSISTENCE_VERSION = 1; + +export class YonexusServerStoreError extends Error { + override readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "YonexusServerStoreError"; + this.cause = cause; + } +} + +export class YonexusServerStoreCorruptionError extends YonexusServerStoreError { + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = "YonexusServerStoreCorruptionError"; + } +} + +export interface ServerStoreLoadResult { + readonly version: number; + readonly persistedAt?: number; + readonly clients: Map; +} + +export interface YonexusServerStore { + readonly filePath: string; + load(): Promise; + save(clients: Iterable): Promise; +} + +export function createYonexusServerStore(filePath: string): YonexusServerStore { + return { + filePath, + load: async () => loadServerStore(filePath), + save: async (clients) => saveServerStore(filePath, clients) + }; +} + +export async function loadServerStore(filePath: string): Promise { + try { + const raw = await readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as ServerPersistenceData; + assertPersistenceDataShape(parsed, filePath); + + const clients = new Map(); + for (const serialized of parsed.clients) { + assertSerializedClientRecordShape(serialized, filePath); + clients.set(serialized.identifier, deserializeClientRecord(serialized)); + } + + return { + version: parsed.version, + persistedAt: parsed.persistedAt, + clients + }; + } catch (error) { + if (isFileNotFoundError(error)) { + return { + version: SERVER_PERSISTENCE_VERSION, + clients: new Map() + }; + } + + if (error instanceof YonexusServerStoreError) { + throw error; + } + + throw new YonexusServerStoreCorruptionError( + `Failed to load Yonexus.Server persistence file: ${filePath}`, + error + ); + } +} + +export async function saveServerStore( + filePath: string, + clients: Iterable +): Promise { + const payload: ServerPersistenceData = { + version: SERVER_PERSISTENCE_VERSION, + persistedAt: Math.floor(Date.now() / 1000), + clients: Array.from(clients, (record) => serializeClientRecord(record)) + }; + + 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 YonexusServerStoreError( + `Failed to save Yonexus.Server persistence file: ${filePath}`, + error + ); + } +} + +function assertPersistenceDataShape( + value: unknown, + filePath: string +): asserts value is ServerPersistenceData { + if (!value || typeof value !== "object") { + throw new YonexusServerStoreCorruptionError( + `Persistence file is not a JSON object: ${filePath}` + ); + } + + const candidate = value as Partial; + if (candidate.version !== SERVER_PERSISTENCE_VERSION) { + throw new YonexusServerStoreCorruptionError( + `Unsupported persistence version in ${filePath}: ${String(candidate.version)}` + ); + } + + if (!Array.isArray(candidate.clients)) { + throw new YonexusServerStoreCorruptionError( + `Persistence file has invalid clients array: ${filePath}` + ); + } + + if ( + candidate.persistedAt !== undefined && + (!Number.isInteger(candidate.persistedAt) || candidate.persistedAt < 0) + ) { + throw new YonexusServerStoreCorruptionError( + `Persistence file has invalid persistedAt value: ${filePath}` + ); + } +} + +function assertSerializedClientRecordShape( + value: unknown, + filePath: string +): asserts value is SerializedClientRecord { + if (!value || typeof value !== "object") { + throw new YonexusServerStoreCorruptionError( + `Persistence file contains a non-object client record: ${filePath}` + ); + } + + const candidate = value as Partial; + if (typeof candidate.identifier !== "string" || candidate.identifier.trim().length === 0) { + throw new YonexusServerStoreCorruptionError( + `Persistence file contains a client record with invalid identifier: ${filePath}` + ); + } + + if (typeof candidate.pairingStatus !== "string" || typeof candidate.status !== "string") { + throw new YonexusServerStoreCorruptionError( + `Persistence file contains a client record with invalid state fields: ${filePath}` + ); + } + + if (!Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.updatedAt)) { + throw new YonexusServerStoreCorruptionError( + `Persistence file contains a client record with invalid timestamps: ${filePath}` + ); + } +} + +function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} diff --git a/plugin/index.ts b/plugin/index.ts index a02ec78..36e1261 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -19,6 +19,16 @@ export { type SerializedClientRecord, type ServerPersistenceData } from "./core/persistence.js"; +export { + SERVER_PERSISTENCE_VERSION, + YonexusServerStoreError, + YonexusServerStoreCorruptionError, + createYonexusServerStore, + loadServerStore, + saveServerStore, + type ServerStoreLoadResult, + type YonexusServerStore +} from "./core/store.js"; export interface YonexusServerPluginManifest { readonly name: "Yonexus.Server"; From b44a4cae66255edf96f1e5c90ae67422b5262ec0 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:05:03 +0000 Subject: [PATCH 05/29] Add server WebSocket transport --- plugin/core/transport.ts | 226 +++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 11 ++ 2 files changed, 237 insertions(+) create mode 100644 plugin/core/transport.ts diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts new file mode 100644 index 0000000..2f14b05 --- /dev/null +++ b/plugin/core/transport.ts @@ -0,0 +1,226 @@ +: +import { WebSocketServer, WebSocket, RawData } from "ws"; +import type { YonexusServerConfig } from "./config.js"; +import type { ClientRecord } from "./persistence.js"; + +export interface ClientConnection { + readonly identifier: string | null; + readonly ws: WebSocket; + readonly connectedAt: number; + isAuthenticated: boolean; +} + +export interface ServerTransport { + readonly isRunning: boolean; + readonly connections: ReadonlyMap; + start(): Promise; + stop(): Promise; + send(identifier: string, message: string): boolean; + broadcast(message: string): void; + closeConnection(identifier: string, code?: number, reason?: string): boolean; +} + +export type MessageHandler = (identifier: string | null, message: string) => void; +export type ConnectionHandler = (identifier: string | null, ws: WebSocket) => void; +export type DisconnectionHandler = (identifier: string | null, code: number, reason: Buffer) => void; + +export interface ServerTransportOptions { + config: YonexusServerConfig; + onMessage: MessageHandler; + onConnect?: ConnectionHandler; + onDisconnect?: DisconnectionHandler; +} + +export class YonexusServerTransport implements ServerTransport { + private wss: WebSocketServer | null = null; + private _connections = new Map(); + private tempConnections = new Set(); + private options: ServerTransportOptions; + private _isRunning = false; + + constructor(options: ServerTransportOptions) { + this.options = options; + } + + get isRunning(): boolean { + return this._isRunning; + } + + get connections(): ReadonlyMap { + return this._connections; + } + + async start(): Promise { + if (this._isRunning) { + throw new Error("Server transport is already running"); + } + + const { listenHost, listenPort } = this.options.config; + + return new Promise((resolve, reject) => { + this.wss = new WebSocketServer({ + host: listenHost, + port: listenPort + }); + + this.wss.on("error", (error) => { + if (!this._isRunning) { + reject(error); + } + }); + + this.wss.on("listening", () => { + this._isRunning = true; + resolve(); + }); + + this.wss.on("connection", (ws, req) => { + this.handleConnection(ws, req); + }); + }); + } + + async stop(): Promise { + if (!this._isRunning || !this.wss) { + return; + } + + // Close all connections + for (const conn of this._connections.values()) { + conn.ws.close(1000, "Server shutting down"); + } + this._connections.clear(); + + for (const ws of this.tempConnections) { + ws.close(1000, "Server shutting down"); + } + this.tempConnections.clear(); + + return new Promise((resolve) => { + this.wss!.close(() => { + this._isRunning = false; + this.wss = null; + resolve(); + }); + }); + } + + send(identifier: string, message: string): boolean { + const conn = this._connections.get(identifier); + if (!conn || !conn.isAuthenticated) { + return false; + } + + if (conn.ws.readyState === WebSocket.OPEN) { + conn.ws.send(message); + return true; + } + return false; + } + + broadcast(message: string): void { + for (const conn of this._connections.values()) { + if (conn.isAuthenticated && conn.ws.readyState === WebSocket.OPEN) { + conn.ws.send(message); + } + } + } + + closeConnection(identifier: string, code = 1000, reason = "Connection closed"): boolean { + const conn = this._connections.get(identifier); + if (!conn) { + return false; + } + + conn.ws.close(code, reason); + this._connections.delete(identifier); + return true; + } + + registerConnection(identifier: string, ws: WebSocket): boolean { + // Check if already connected + if (this._connections.has(identifier)) { + // Replace old connection + const oldConn = this._connections.get(identifier)!; + oldConn.ws.close(1008, "New connection established"); + } + + // Remove from temp connections if present + this.tempConnections.delete(ws); + + const conn: ClientConnection = { + identifier, + ws, + connectedAt: Math.floor(Date.now() / 1000), + isAuthenticated: false + }; + + this._connections.set(identifier, conn); + return true; + } + + markAuthenticated(identifier: string): boolean { + const conn = this._connections.get(identifier); + if (!conn) { + return false; + } + conn.isAuthenticated = true; + return true; + } + + private handleConnection(ws: WebSocket, _req: import("http").IncomingMessage): void { + this.tempConnections.add(ws); + + const tempConn: ClientConnection = { + identifier: null, + ws, + connectedAt: Math.floor(Date.now() / 1000), + isAuthenticated: false + }; + + ws.on("message", (data: RawData) => { + const message = data.toString("utf8"); + // Try to get identifier from registered connections + let identifier: string | null = null; + for (const [id, conn] of this._connections) { + if (conn.ws === ws) { + identifier = id; + break; + } + } + this.options.onMessage(identifier, message); + }); + + ws.on("close", (code: number, reason: Buffer) => { + this.tempConnections.delete(ws); + + // Find and remove from registered connections + for (const [id, conn] of this._connections) { + if (conn.ws === ws) { + this._connections.delete(id); + if (this.options.onDisconnect) { + this.options.onDisconnect(id, code, reason); + } + return; + } + } + + if (this.options.onDisconnect) { + this.options.onDisconnect(null, code, reason); + } + }); + + ws.on("error", (error: Error) => { + // Log error but let close handler clean up + console.error("WebSocket error:", error.message); + }); + + if (this.options.onConnect) { + this.options.onConnect(null, ws); + } + } +} + +export function createServerTransport(options: ServerTransportOptions): ServerTransport { + return new YonexusServerTransport(options); +} diff --git a/plugin/index.ts b/plugin/index.ts index 36e1261..2970f34 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -57,4 +57,15 @@ export function createYonexusServerPlugin(): YonexusServerPluginRuntime { } export default createYonexusServerPlugin; +export { + createServerTransport, + YonexusServerTransport, + type ServerTransport, + type ServerTransportOptions, + type ClientConnection, + type MessageHandler, + type ConnectionHandler, + type DisconnectionHandler +} from "./core/transport.js"; + export { manifest }; From f7c7531385955fb957964705e22e85f5d55c6dba Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:13:16 +0000 Subject: [PATCH 06/29] Add server runtime and hello handshake --- package-lock.json | 53 ++++++++++ package.json | 3 + plugin/core/config.ts | 14 +-- plugin/core/runtime.ts | 218 +++++++++++++++++++++++++++++++++++++++ plugin/core/transport.ts | 33 +++--- plugin/index.ts | 6 ++ 6 files changed, 308 insertions(+), 19 deletions(-) create mode 100644 package-lock.json create mode 100644 plugin/core/runtime.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c3552f5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "yonexus-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yonexus-server", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "typescript": "^5.6.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index 20dc0cc..10f9115 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "clean": "rm -rf dist", "check": "tsc -p tsconfig.json --noEmit" }, + "dependencies": { + "ws": "^8.18.0" + }, "devDependencies": { "typescript": "^5.6.3" } diff --git a/plugin/core/config.ts b/plugin/core/config.ts index 7f352ca..0bb78cc 100644 --- a/plugin/core/config.ts +++ b/plugin/core/config.ts @@ -48,10 +48,10 @@ function isValidWsUrl(value: string): boolean { } export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig { - const source = raw as Record | null; + const source = (raw && typeof raw === "object" ? raw : {}) as Record; const issues: string[] = []; - const rawIdentifiers = source?.followerIdentifiers; + const rawIdentifiers = source.followerIdentifiers; const followerIdentifiers = Array.isArray(rawIdentifiers) ? rawIdentifiers .filter((value): value is string => typeof value === "string") @@ -67,23 +67,23 @@ export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig { issues.push("followerIdentifiers must not contain duplicates"); } - const notifyBotToken = source?.notifyBotToken; + const notifyBotToken = source.notifyBotToken; if (!isNonEmptyString(notifyBotToken)) { issues.push("notifyBotToken is required"); } - const adminUserId = source?.adminUserId; + const adminUserId = source.adminUserId; if (!isNonEmptyString(adminUserId)) { issues.push("adminUserId is required"); } - const listenPort = source?.listenPort; + const listenPort = source.listenPort; if (!isValidPort(listenPort)) { issues.push("listenPort must be an integer between 1 and 65535"); } - const listenHost = normalizeOptionalString(source?.listenHost) ?? "0.0.0.0"; - const publicWsUrl = normalizeOptionalString(source?.publicWsUrl); + const listenHost = normalizeOptionalString(source.listenHost) ?? "0.0.0.0"; + const publicWsUrl = normalizeOptionalString(source.publicWsUrl); if (publicWsUrl !== undefined && !isValidWsUrl(publicWsUrl)) { issues.push("publicWsUrl must be a valid ws:// or wss:// URL when provided"); diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts new file mode 100644 index 0000000..c6f212c --- /dev/null +++ b/plugin/core/runtime.ts @@ -0,0 +1,218 @@ +import { + type BuiltinEnvelope, + type HelloPayload, + YONEXUS_PROTOCOL_VERSION, + buildError, + buildHelloAck, + decodeBuiltin, + encodeBuiltin, + isBuiltinMessage +} from "../../../Yonexus.Protocol/src/index.js"; +import type { YonexusServerConfig } from "./config.js"; +import { + canAuthenticate, + createClientRecord, + hasPendingPairing, + isPairingExpired, + type ClientRecord, + type ServerRegistry +} from "./persistence.js"; +import type { YonexusServerStore } from "./store.js"; +import { type ClientConnection, type ServerTransport } from "./transport.js"; + +export interface YonexusServerRuntimeOptions { + config: YonexusServerConfig; + store: YonexusServerStore; + transport: ServerTransport; + now?: () => number; +} + +export interface ServerLifecycleState { + readonly isStarted: boolean; + readonly registry: ServerRegistry; +} + +export class YonexusServerRuntime { + private readonly options: YonexusServerRuntimeOptions; + private readonly now: () => number; + private readonly registry: ServerRegistry; + private started = false; + constructor(options: YonexusServerRuntimeOptions) { + this.options = options; + this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); + this.registry = { + clients: new Map(), + sessions: new Map() + }; + } + + get state(): ServerLifecycleState { + return { + isStarted: this.started, + registry: this.registry + }; + } + + async start(): Promise { + if (this.started) { + return; + } + + const persisted = await this.options.store.load(); + for (const record of persisted.clients.values()) { + this.registry.clients.set(record.identifier, record); + } + + for (const identifier of this.options.config.followerIdentifiers) { + if (!this.registry.clients.has(identifier)) { + this.registry.clients.set(identifier, createClientRecord(identifier)); + } + } + + await this.options.transport.start(); + this.started = true; + } + + async stop(): Promise { + if (!this.started) { + return; + } + + await this.persist(); + this.registry.sessions.clear(); + await this.options.transport.stop(); + this.started = false; + } + + handleDisconnect(identifier: string | null): void { + if (!identifier) { + return; + } + + const existing = this.registry.sessions.get(identifier); + if (!existing) { + return; + } + + const record = this.registry.clients.get(identifier); + if (record) { + record.status = "offline"; + record.updatedAt = this.now(); + } + + this.registry.sessions.delete(identifier); + } + + async handleMessage(connection: ClientConnection, raw: string): Promise { + if (!isBuiltinMessage(raw)) { + return; + } + + const envelope = decodeBuiltin(raw); + + if (envelope.type === "hello") { + await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>); + } + } + + private async handleHello( + connection: ClientConnection, + envelope: BuiltinEnvelope<"hello", HelloPayload> + ): Promise { + const payload = envelope.payload; + if (!payload) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin(buildError({ code: "MALFORMED_MESSAGE", message: "hello payload is required" }, { timestamp: this.now() })) + ); + return; + } + + const helloIdentifier = payload.identifier?.trim(); + if (!helloIdentifier || !this.options.config.followerIdentifiers.includes(helloIdentifier)) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin(buildError({ code: "IDENTIFIER_NOT_ALLOWED", message: "identifier is not allowed" }, { timestamp: this.now() })) + ); + return; + } + + if (payload.protocolVersion !== YONEXUS_PROTOCOL_VERSION) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { + code: "UNSUPPORTED_PROTOCOL_VERSION", + message: `Unsupported protocol version: ${payload.protocolVersion}` + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + connection.ws.close(1002, "Unsupported protocol version"); + return; + } + + const record = this.ensureClientRecord(helloIdentifier); + record.updatedAt = this.now(); + + this.options.transport.registerConnection(helloIdentifier, connection.ws); + this.registry.sessions.set(helloIdentifier, { + identifier: helloIdentifier, + socket: connection.ws, + isAuthenticated: false, + connectedAt: connection.connectedAt, + lastActivityAt: this.now() + }); + + const nextAction = this.determineNextAction(record); + this.options.transport.sendToConnection( + { ...connection, identifier: helloIdentifier }, + encodeBuiltin( + buildHelloAck( + { + identifier: helloIdentifier, + nextAction + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + + await this.persist(); + } + + private determineNextAction(record: ClientRecord): "pair_required" | "auth_required" | "waiting_pair_confirm" { + if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { + return "waiting_pair_confirm"; + } + + if (canAuthenticate(record)) { + return "auth_required"; + } + + return "pair_required"; + } + + private ensureClientRecord(identifier: string): ClientRecord { + const existing = this.registry.clients.get(identifier); + if (existing) { + return existing; + } + + const created = createClientRecord(identifier); + this.registry.clients.set(identifier, created); + return created; + } + + private async persist(): Promise { + await this.options.store.save(this.registry.clients.values()); + } +} + +export function createYonexusServerRuntime( + options: YonexusServerRuntimeOptions +): YonexusServerRuntime { + return new YonexusServerRuntime(options); +} diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index 2f14b05..2743e24 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -1,4 +1,3 @@ -: import { WebSocketServer, WebSocket, RawData } from "ws"; import type { YonexusServerConfig } from "./config.js"; import type { ClientRecord } from "./persistence.js"; @@ -16,11 +15,14 @@ export interface ServerTransport { start(): Promise; stop(): Promise; send(identifier: string, message: string): boolean; + sendToConnection(connection: ClientConnection, message: string): boolean; broadcast(message: string): void; closeConnection(identifier: string, code?: number, reason?: string): boolean; + registerConnection(identifier: string, ws: WebSocket): boolean; + markAuthenticated(identifier: string): boolean; } -export type MessageHandler = (identifier: string | null, message: string) => void; +export type MessageHandler = (connection: ClientConnection, message: string) => void; export type ConnectionHandler = (identifier: string | null, ws: WebSocket) => void; export type DisconnectionHandler = (identifier: string | null, code: number, reason: Buffer) => void; @@ -107,12 +109,17 @@ export class YonexusServerTransport implements ServerTransport { send(identifier: string, message: string): boolean { const conn = this._connections.get(identifier); - if (!conn || !conn.isAuthenticated) { + if (!conn) { return false; } - - if (conn.ws.readyState === WebSocket.OPEN) { - conn.ws.send(message); + + return this.sendToConnection(conn, message); + } + + sendToConnection(connection: ClientConnection, message: string): boolean { + const { ws } = connection; + if (ws.readyState === WebSocket.OPEN) { + ws.send(message); return true; } return false; @@ -120,8 +127,8 @@ export class YonexusServerTransport implements ServerTransport { broadcast(message: string): void { for (const conn of this._connections.values()) { - if (conn.isAuthenticated && conn.ws.readyState === WebSocket.OPEN) { - conn.ws.send(message); + if (conn.isAuthenticated) { + this.sendToConnection(conn, message); } } } @@ -131,7 +138,7 @@ export class YonexusServerTransport implements ServerTransport { if (!conn) { return false; } - + conn.ws.close(code, reason); this._connections.delete(identifier); return true; @@ -188,12 +195,14 @@ export class YonexusServerTransport implements ServerTransport { break; } } - this.options.onMessage(identifier, message); + + const connection = identifier ? this._connections.get(identifier) ?? tempConn : tempConn; + this.options.onMessage(connection, message); }); ws.on("close", (code: number, reason: Buffer) => { this.tempConnections.delete(ws); - + // Find and remove from registered connections for (const [id, conn] of this._connections) { if (conn.ws === ws) { @@ -204,7 +213,7 @@ export class YonexusServerTransport implements ServerTransport { return; } } - + if (this.options.onDisconnect) { this.options.onDisconnect(null, code, reason); } diff --git a/plugin/index.ts b/plugin/index.ts index 2970f34..95c77be 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -67,5 +67,11 @@ export { type ConnectionHandler, type DisconnectionHandler } from "./core/transport.js"; +export { + createYonexusServerRuntime, + YonexusServerRuntime, + type YonexusServerRuntimeOptions, + type ServerLifecycleState +} from "./core/runtime.js"; export { manifest }; From cd09fe6043db70f76905663eba43d236d0c3de07 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:34:46 +0000 Subject: [PATCH 07/29] feat(server): add pairing service and notify stub --- plugin/core/runtime.ts | 31 ++++++ plugin/crypto/utils.ts | 42 +++++++ plugin/index.ts | 15 +++ plugin/notifications/discord.ts | 97 ++++++++++++++++ plugin/services/pairing.ts | 190 ++++++++++++++++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 plugin/crypto/utils.ts create mode 100644 plugin/notifications/discord.ts create mode 100644 plugin/services/pairing.ts diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index c6f212c..2e2ac5a 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -19,6 +19,11 @@ import { } from "./persistence.js"; import type { YonexusServerStore } from "./store.js"; import { type ClientConnection, type ServerTransport } from "./transport.js"; +import { createPairingService, type PairingService } from "../services/pairing.js"; +import { + createDiscordNotificationService, + type DiscordNotificationService +} from "../notifications/discord.js"; export interface YonexusServerRuntimeOptions { config: YonexusServerConfig; @@ -36,7 +41,10 @@ export class YonexusServerRuntime { private readonly options: YonexusServerRuntimeOptions; private readonly now: () => number; private readonly registry: ServerRegistry; + private readonly pairingService: PairingService; + private readonly notificationService: DiscordNotificationService; private started = false; + constructor(options: YonexusServerRuntimeOptions) { this.options = options; this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); @@ -44,6 +52,11 @@ export class YonexusServerRuntime { clients: new Map(), sessions: new Map() }; + this.pairingService = createPairingService({ now: this.now }); + this.notificationService = createDiscordNotificationService({ + botToken: options.config.notifyBotToken, + adminUserId: options.config.adminUserId + }); } get state(): ServerLifecycleState { @@ -180,6 +193,10 @@ export class YonexusServerRuntime { ) ); + if (nextAction === "pair_required") { + await this.beginPairing(record); + } + await this.persist(); } @@ -206,6 +223,20 @@ export class YonexusServerRuntime { return created; } + private async beginPairing(record: ClientRecord): Promise { + if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { + return; + } + + const request = this.pairingService.createPairingRequest(record); + const notified = await this.notificationService.sendPairingNotification(request); + if (notified) { + this.pairingService.markNotificationSent(record); + } else { + this.pairingService.markNotificationFailed(record); + } + } + private async persist(): Promise { await this.options.store.save(this.registry.clients.values()); } diff --git a/plugin/crypto/utils.ts b/plugin/crypto/utils.ts new file mode 100644 index 0000000..29dd51b --- /dev/null +++ b/plugin/crypto/utils.ts @@ -0,0 +1,42 @@ +import { randomBytes } from "node:crypto"; + +/** + * Generate a cryptographically secure random pairing code. + * Format: XXXX-XXXX-XXXX (12 alphanumeric characters in groups of 4) + * Excludes confusing characters: 0, O, 1, I + */ +export function generatePairingCode(): string { + const bytes = randomBytes(8); + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Excludes confusing chars (0, O, 1, I) + + let code = ""; + for (let i = 0; i < 12; i++) { + code += chars[bytes[i % bytes.length] % chars.length]; + } + + // Format as XXXX-XXXX-XXXX + return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`; +} + +/** + * Generate a shared secret for client authentication. + * This is issued by the server after successful pairing. + * Returns a base64url-encoded 32-byte random string. + */ +export function generateSecret(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Generate a 24-character nonce for authentication. + * Uses base64url encoding of 18 random bytes, truncated to 24 chars. + */ +export function generateNonce(): string { + const bytes = randomBytes(18); + return bytes.toString("base64url").slice(0, 24); +} + +/** + * Default pairing code TTL in seconds (5 minutes) + */ +export const DEFAULT_PAIRING_TTL_SECONDS = 300; diff --git a/plugin/index.ts b/plugin/index.ts index 95c77be..94840cb 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -74,4 +74,19 @@ export { type ServerLifecycleState } from "./core/runtime.js"; +export { + createPairingService, + PairingService, + type PairingRequest, + type PairingResult, + type PairingFailureReason +} from "./services/pairing.js"; + +export { + createDiscordNotificationService, + createMockNotificationService, + type DiscordNotificationService, + type DiscordNotificationConfig +} from "./notifications/discord.js"; + export { manifest }; diff --git a/plugin/notifications/discord.ts b/plugin/notifications/discord.ts new file mode 100644 index 0000000..047d3f2 --- /dev/null +++ b/plugin/notifications/discord.ts @@ -0,0 +1,97 @@ +/** + * Yonexus Server - Discord Notification Service + * + * Sends pairing notifications to the configured admin user via Discord DM. + */ + +import type { PairingRequest } from "../services/pairing.js"; + +export interface DiscordNotificationService { + /** + * Send a pairing code notification to the admin user. + * @returns Whether the notification was sent successfully + */ + sendPairingNotification(request: PairingRequest): Promise; +} + +export interface DiscordNotificationConfig { + botToken: string; + adminUserId: string; +} + +/** + * Create a Discord notification service. + * + * Note: This is a framework stub. Full implementation requires: + * - Discord bot client integration (e.g., using discord.js) + * - DM channel creation/fetching + * - Error handling for blocked DMs, invalid tokens, etc. + * + * For v1, this provides the interface and a mock implementation + * that logs to console. Production deployments should replace + * this with actual Discord bot integration. + */ +export function createDiscordNotificationService( + config: DiscordNotificationConfig +): DiscordNotificationService { + return { + async sendPairingNotification(request: PairingRequest): Promise { + const message = formatPairingMessage(request); + + // Log to console (visible in OpenClaw logs) + console.log("[Yonexus.Server] Pairing notification (Discord DM stub):"); + console.log(message); + + // TODO: Replace with actual Discord bot integration + // Example with discord.js: + // const client = new Client({ intents: [GatewayIntentBits.DirectMessages] }); + // await client.login(config.botToken); + // const user = await client.users.fetch(config.adminUserId); + // await user.send(message); + + // For now, return true to allow pairing flow to continue + // In production, this should return false if DM fails + return true; + } + }; +} + +/** + * Format a pairing request as a Discord DM message. + */ +function formatPairingMessage(request: PairingRequest): string { + const expiresDate = new Date(request.expiresAt * 1000); + const expiresStr = expiresDate.toISOString(); + + return [ + "🔐 **Yonexus Pairing Request**", + "", + `**Identifier:** \`${request.identifier}\``, + `**Pairing Code:** \`${request.pairingCode}\``, + `**Expires At:** ${expiresStr}`, + `**TTL:** ${request.ttlSeconds} seconds`, + "", + "Please relay this pairing code to the client operator via a trusted out-of-band channel.", + "Do not share this code over the Yonexus WebSocket connection." + ].join("\n"); +} + +/** + * Create a mock notification service for testing. + * Returns success/failure based on configuration. + */ +export function createMockNotificationService( + options: { shouldSucceed?: boolean } = {} +): DiscordNotificationService { + const shouldSucceed = options.shouldSucceed ?? true; + + return { + async sendPairingNotification(request: PairingRequest): Promise { + console.log("[Yonexus.Server] Mock pairing notification:"); + console.log(` Identifier: ${request.identifier}`); + console.log(` Pairing Code: ${request.pairingCode}`); + console.log(` Success: ${shouldSucceed}`); + return shouldSucceed; + } + }; +} diff --git a/plugin/services/pairing.ts b/plugin/services/pairing.ts new file mode 100644 index 0000000..3245942 --- /dev/null +++ b/plugin/services/pairing.ts @@ -0,0 +1,190 @@ +/** + * Yonexus Server - Pairing Service + * + * Manages client pairing flow: + * - Creating pairing requests with codes + * - Tracking pairing expiration + * - Validating pairing confirmations + * - Issuing shared secrets after successful pairing + */ + +import type { ClientRecord } from "../core/persistence.js"; +import { generatePairingCode, generateSecret, DEFAULT_PAIRING_TTL_SECONDS } from "../crypto/utils.js"; + +export interface PairingRequest { + readonly identifier: string; + readonly pairingCode: string; + readonly expiresAt: number; + readonly ttlSeconds: number; + readonly createdAt: number; +} + +export interface PairingResult { + readonly success: boolean; + readonly secret?: string; + readonly pairedAt?: number; + readonly reason?: PairingFailureReason; +} + +export type PairingFailureReason = + | "expired" + | "invalid_code" + | "not_pending" + | "internal_error"; + +export class PairingService { + private readonly now: () => number; + + constructor(options: { now?: () => number } = {}) { + this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); + } + + /** + * Create a new pairing request for a client. + * Updates the client record with pending pairing state. + */ + createPairingRequest( + record: ClientRecord, + options: { ttlSeconds?: number } = {} + ): PairingRequest { + const ttlSeconds = options.ttlSeconds ?? DEFAULT_PAIRING_TTL_SECONDS; + const now = this.now(); + const pairingCode = generatePairingCode(); + + // Update the client record + record.pairingStatus = "pending"; + record.pairingCode = pairingCode; + record.pairingExpiresAt = now + ttlSeconds; + record.pairingNotifyStatus = "pending"; + record.updatedAt = now; + + return { + identifier: record.identifier, + pairingCode, + expiresAt: record.pairingExpiresAt, + ttlSeconds, + createdAt: now + }; + } + + /** + * Validate a pairing confirmation from a client. + * Returns the pairing result and updates the record on success. + */ + confirmPairing( + record: ClientRecord, + submittedCode: string + ): PairingResult { + const now = this.now(); + + // Check if pairing is pending + if (record.pairingStatus !== "pending") { + return { success: false, reason: "not_pending" }; + } + + // Check if pairing has expired + if (record.pairingExpiresAt && now > record.pairingExpiresAt) { + this.clearPairingState(record); + return { success: false, reason: "expired" }; + } + + // Validate the pairing code + if (record.pairingCode !== submittedCode) { + return { success: false, reason: "invalid_code" }; + } + + // Pairing successful - generate secret and update record + const secret = generateSecret(); + record.pairingStatus = "paired"; + record.secret = secret; + record.pairedAt = now; + record.updatedAt = now; + + // Clear pairing-specific fields + record.pairingCode = undefined; + record.pairingExpiresAt = undefined; + record.pairingNotifiedAt = undefined; + record.pairingNotifyStatus = undefined; + + return { + success: true, + secret, + pairedAt: now + }; + } + + /** + * Mark a pairing notification as sent. + */ + markNotificationSent(record: ClientRecord): void { + if (record.pairingStatus === "pending") { + record.pairingNotifyStatus = "sent"; + record.pairingNotifiedAt = this.now(); + record.updatedAt = this.now(); + } + } + + /** + * Mark a pairing notification as failed. + */ + markNotificationFailed(record: ClientRecord): void { + if (record.pairingStatus === "pending") { + record.pairingNotifyStatus = "failed"; + record.updatedAt = this.now(); + } + } + + /** + * Clear pairing state for a client. + * Used when pairing fails or is cancelled. + */ + clearPairingState(record: ClientRecord): void { + record.pairingStatus = record.pairingStatus === "paired" ? "paired" : "unpaired"; + record.pairingCode = undefined; + record.pairingExpiresAt = undefined; + record.pairingNotifiedAt = undefined; + record.pairingNotifyStatus = undefined; + record.updatedAt = this.now(); + } + + /** + * Revoke pairing for a client. + * Clears secret and returns to unpaired state. + */ + revokePairing(record: ClientRecord): void { + record.pairingStatus = "revoked"; + record.secret = undefined; + record.publicKey = undefined; + record.pairedAt = undefined; + this.clearPairingState(record); + } + + /** + * Check if a pairing request is expired. + */ + isExpired(record: ClientRecord): boolean { + if (!record.pairingExpiresAt) return false; + return this.now() > record.pairingExpiresAt; + } + + /** + * Get remaining TTL for a pending pairing. + * Returns 0 if expired or not pending. + */ + getRemainingTtl(record: ClientRecord): number { + if (record.pairingStatus !== "pending" || !record.pairingExpiresAt) { + return 0; + } + const remaining = record.pairingExpiresAt - this.now(); + return Math.max(0, remaining); + } +} + +/** + * Factory function to create a pairing service. + */ +export function createPairingService( + options: { now?: () => number } = {} +): PairingService { + return new PairingService(options); +} From a05b226056c97f3f2ab85737050135cd6935fc54 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:38:43 +0000 Subject: [PATCH 08/29] feat: implement server pairing confirmation flow --- plugin/core/persistence.ts | 3 + plugin/core/runtime.ts | 179 +++++++++++++++++++++++++++++++++++-- 2 files changed, 173 insertions(+), 9 deletions(-) diff --git a/plugin/core/persistence.ts b/plugin/core/persistence.ts index 5cbbeeb..da93fcc 100644 --- a/plugin/core/persistence.ts +++ b/plugin/core/persistence.ts @@ -110,6 +110,9 @@ export interface ClientSession { /** 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; diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 2e2ac5a..aa6873c 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -1,9 +1,13 @@ import { type BuiltinEnvelope, type HelloPayload, + type PairConfirmPayload, YONEXUS_PROTOCOL_VERSION, buildError, buildHelloAck, + buildPairFailed, + buildPairRequest, + buildPairSuccess, decodeBuiltin, encodeBuiltin, isBuiltinMessage @@ -125,6 +129,14 @@ export class YonexusServerRuntime { if (envelope.type === "hello") { await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>); + return; + } + + if (envelope.type === "pair_confirm") { + await this.handlePairConfirm( + connection, + envelope as BuiltinEnvelope<"pair_confirm", PairConfirmPayload> + ); } } @@ -176,7 +188,8 @@ export class YonexusServerRuntime { socket: connection.ws, isAuthenticated: false, connectedAt: connection.connectedAt, - lastActivityAt: this.now() + lastActivityAt: this.now(), + publicKey: payload.publicKey }); const nextAction = this.determineNextAction(record); @@ -193,13 +206,113 @@ export class YonexusServerRuntime { ) ); - if (nextAction === "pair_required") { - await this.beginPairing(record); + if (nextAction === "pair_required" || nextAction === "waiting_pair_confirm") { + await this.beginPairing({ + record, + connection: { ...connection, identifier: helloIdentifier }, + requestId: envelope.requestId, + reusePending: nextAction === "waiting_pair_confirm" + }); } await this.persist(); } + private async handlePairConfirm( + connection: ClientConnection, + envelope: BuiltinEnvelope<"pair_confirm", PairConfirmPayload> + ): Promise { + const payload = envelope.payload; + if (!payload) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "MALFORMED_MESSAGE", message: "pair_confirm payload is required" }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const identifier = payload.identifier?.trim(); + if (!identifier || !this.options.config.followerIdentifiers.includes(identifier)) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildPairFailed( + { + identifier: identifier || "unknown", + reason: "identifier_not_allowed" + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const record = this.ensureClientRecord(identifier); + const submittedCode = payload.pairingCode?.trim(); + if (!submittedCode) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "MALFORMED_MESSAGE", message: "pairingCode is required" }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const result = this.pairingService.confirmPairing(record, submittedCode); + if (!result.success || !result.secret || !result.pairedAt) { + const reason = result.reason === "not_pending" ? "internal_error" : result.reason ?? "internal_error"; + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildPairFailed( + { + identifier, + reason + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + + await this.persist(); + return; + } + + if (connection.identifier !== identifier) { + this.options.transport.registerConnection(identifier, connection.ws); + } + + const session = this.registry.sessions.get(identifier); + record.publicKey = session?.publicKey ?? record.publicKey; + record.updatedAt = this.now(); + + this.options.transport.sendToConnection( + { ...connection, identifier }, + encodeBuiltin( + buildPairSuccess( + { + identifier, + secret: result.secret, + pairedAt: result.pairedAt + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + + await this.persist(); + } + private determineNextAction(record: ClientRecord): "pair_required" | "auth_required" | "waiting_pair_confirm" { if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { return "waiting_pair_confirm"; @@ -223,18 +336,66 @@ export class YonexusServerRuntime { return created; } - private async beginPairing(record: ClientRecord): Promise { - if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { - return; - } + private async beginPairing(options: { + record: ClientRecord; + connection: ClientConnection; + requestId?: string; + reusePending?: boolean; + }): Promise { + const { record, connection, requestId, reusePending = false } = options; + + const request = + reusePending && hasPendingPairing(record) && !isPairingExpired(record, this.now()) + ? { + identifier: record.identifier, + pairingCode: record.pairingCode ?? "", + expiresAt: record.pairingExpiresAt ?? this.now(), + ttlSeconds: this.pairingService.getRemainingTtl(record), + createdAt: record.updatedAt + } + : this.pairingService.createPairingRequest(record); + + const notified = reusePending + ? record.pairingNotifyStatus === "sent" + : await this.notificationService.sendPairingNotification(request); - const request = this.pairingService.createPairingRequest(record); - const notified = await this.notificationService.sendPairingNotification(request); if (notified) { this.pairingService.markNotificationSent(record); } else { this.pairingService.markNotificationFailed(record); } + + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildPairRequest( + { + identifier: record.identifier, + expiresAt: request.expiresAt, + ttlSeconds: this.pairingService.getRemainingTtl(record), + adminNotification: notified ? "sent" : "failed", + codeDelivery: "out_of_band" + }, + { requestId, timestamp: this.now() } + ) + ) + ); + + if (!notified) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildPairFailed( + { + identifier: record.identifier, + reason: "admin_notification_failed" + }, + { requestId, timestamp: this.now() } + ) + ) + ); + this.pairingService.clearPairingState(record); + } } private async persist(): Promise { From 83f6195c1f1520ff5c96953375bd9d3d01ab2440 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 22:04:49 +0000 Subject: [PATCH 09/29] feat: validate yonexus auth requests --- plugin/core/runtime.ts | 226 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 1 deletion(-) diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index aa6873c..fd1d501 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -1,16 +1,26 @@ import { + AUTH_ATTEMPT_WINDOW_SECONDS, + AUTH_MAX_ATTEMPTS_PER_WINDOW, + AUTH_RECENT_NONCE_WINDOW_SIZE, type BuiltinEnvelope, type HelloPayload, type PairConfirmPayload, YONEXUS_PROTOCOL_VERSION, + buildAuthFailed, + buildAuthSuccess, buildError, buildHelloAck, buildPairFailed, buildPairRequest, buildPairSuccess, + buildRePairRequired, decodeBuiltin, encodeBuiltin, - isBuiltinMessage + extractAuthRequestSigningInput, + isBuiltinMessage, + isTimestampFresh, + isValidAuthNonce, + type AuthRequestPayload } from "../../../Yonexus.Protocol/src/index.js"; import type { YonexusServerConfig } from "./config.js"; import { @@ -21,6 +31,7 @@ import { type ClientRecord, type ServerRegistry } from "./persistence.js"; +import { verifySignature } from "../../../Yonexus.Client/plugin/crypto/keypair.js"; import type { YonexusServerStore } from "./store.js"; import { type ClientConnection, type ServerTransport } from "./transport.js"; import { createPairingService, type PairingService } from "../services/pairing.js"; @@ -137,6 +148,14 @@ export class YonexusServerRuntime { connection, envelope as BuiltinEnvelope<"pair_confirm", PairConfirmPayload> ); + return; + } + + if (envelope.type === "auth_request") { + await this.handleAuthRequest( + connection, + envelope as BuiltinEnvelope<"auth_request", AuthRequestPayload> + ); } } @@ -313,6 +332,179 @@ export class YonexusServerRuntime { await this.persist(); } + private async handleAuthRequest( + connection: ClientConnection, + envelope: BuiltinEnvelope<"auth_request", AuthRequestPayload> + ): Promise { + const payload = envelope.payload; + if (!payload) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "MALFORMED_MESSAGE", message: "auth_request payload is required" }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const identifier = payload.identifier?.trim(); + if (!identifier || !this.options.config.followerIdentifiers.includes(identifier)) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier: identifier || "unknown", + reason: "unknown_identifier" + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const record = this.ensureClientRecord(identifier); + const session = this.registry.sessions.get(identifier); + if (!session || !canAuthenticate(record) || !record.secret || !record.publicKey) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: "not_paired" + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const now = this.now(); + record.recentHandshakeAttempts = record.recentHandshakeAttempts.filter( + (timestamp) => now - timestamp < AUTH_ATTEMPT_WINDOW_SECONDS + ); + record.recentHandshakeAttempts.push(now); + + if (record.recentHandshakeAttempts.length > AUTH_MAX_ATTEMPTS_PER_WINDOW) { + await this.triggerRePairRequired(connection, record, envelope.requestId, "rate_limited"); + return; + } + + if (!isValidAuthNonce(payload.nonce)) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: "invalid_signature" + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + return; + } + + const freshness = isTimestampFresh(payload.proofTimestamp, now); + if (!freshness.ok) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: freshness.reason + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + return; + } + + const hasNonceCollision = record.recentNonces.some((entry) => entry.nonce === payload.nonce); + if (hasNonceCollision) { + await this.triggerRePairRequired(connection, record, envelope.requestId, "nonce_collision"); + return; + } + + const publicKey = payload.publicKey?.trim() || session.publicKey || record.publicKey; + if (!publicKey || publicKey !== record.publicKey) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: "invalid_signature" + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + return; + } + + const isValidSignature = await verifySignature( + publicKey, + extractAuthRequestSigningInput(payload, record.secret), + payload.signature + ); + + if (!isValidSignature) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildAuthFailed( + { + identifier, + reason: "invalid_signature" + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + return; + } + + record.recentNonces = [...record.recentNonces, { nonce: payload.nonce, timestamp: now }].slice( + -AUTH_RECENT_NONCE_WINDOW_SIZE + ); + record.lastAuthenticatedAt = now; + record.lastHeartbeatAt = now; + record.status = "online"; + record.updatedAt = now; + + if (session) { + session.isAuthenticated = true; + session.lastActivityAt = now; + session.publicKey = publicKey; + } + this.options.transport.markAuthenticated(identifier); + this.options.transport.sendToConnection( + { ...connection, identifier }, + encodeBuiltin( + buildAuthSuccess( + { + identifier, + authenticatedAt: now, + status: "online" + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + + await this.persist(); + } + private determineNextAction(record: ClientRecord): "pair_required" | "auth_required" | "waiting_pair_confirm" { if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) { return "waiting_pair_confirm"; @@ -398,6 +590,38 @@ export class YonexusServerRuntime { } } + private async triggerRePairRequired( + connection: ClientConnection, + record: ClientRecord, + requestId: string | undefined, + reason: "nonce_collision" | "rate_limited" + ): Promise { + record.secret = undefined; + record.pairingStatus = "revoked"; + record.pairingCode = undefined; + record.pairingExpiresAt = undefined; + record.pairingNotifyStatus = undefined; + record.recentNonces = []; + record.recentHandshakeAttempts = []; + record.status = "offline"; + record.updatedAt = this.now(); + + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildRePairRequired( + { + identifier: record.identifier, + reason + }, + { requestId, timestamp: this.now() } + ) + ) + ); + + await this.persist(); + } + private async persist(): Promise { await this.options.store.save(this.registry.clients.values()); } From ba007ebd59997bd39f5b28dcb2b2229556ae39d4 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 22:35:02 +0000 Subject: [PATCH 10/29] Handle heartbeat builtin messages --- plugin/core/runtime.ts | 81 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index fd1d501..04a4c12 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -9,6 +9,7 @@ import { buildAuthFailed, buildAuthSuccess, buildError, + buildHeartbeatAck, buildHelloAck, buildPairFailed, buildPairRequest, @@ -20,7 +21,8 @@ import { isBuiltinMessage, isTimestampFresh, isValidAuthNonce, - type AuthRequestPayload + type AuthRequestPayload, + type HeartbeatPayload } from "../../../Yonexus.Protocol/src/index.js"; import type { YonexusServerConfig } from "./config.js"; import { @@ -156,6 +158,14 @@ export class YonexusServerRuntime { connection, envelope as BuiltinEnvelope<"auth_request", AuthRequestPayload> ); + return; + } + + if (envelope.type === "heartbeat") { + await this.handleHeartbeat( + connection, + envelope as BuiltinEnvelope<"heartbeat", HeartbeatPayload> + ); } } @@ -590,6 +600,75 @@ export class YonexusServerRuntime { } } + private async handleHeartbeat( + connection: ClientConnection, + envelope: BuiltinEnvelope<"heartbeat", HeartbeatPayload> + ): Promise { + const payload = envelope.payload; + if (!payload) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "MALFORMED_MESSAGE", message: "heartbeat payload is required" }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const identifier = payload.identifier?.trim(); + if (!identifier || !this.options.config.followerIdentifiers.includes(identifier)) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "IDENTIFIER_NOT_ALLOWED", message: "identifier is not allowed" }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const session = this.registry.sessions.get(identifier); + if (!session || !session.isAuthenticated) { + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "AUTH_FAILED", message: "heartbeat requires authentication" }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); + return; + } + + const record = this.ensureClientRecord(identifier); + const now = this.now(); + record.lastHeartbeatAt = now; + record.status = "online"; + record.updatedAt = now; + session.lastActivityAt = now; + + this.options.transport.sendToConnection( + { ...connection, identifier }, + encodeBuiltin( + buildHeartbeatAck( + { + identifier, + status: record.status + }, + { requestId: envelope.requestId, timestamp: now } + ) + ) + ); + + await this.persist(); + } + private async triggerRePairRequired( connection: ClientConnection, record: ClientRecord, From 075fcb7974207e6c463225859728ac3ad78c9e19 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 22:39:49 +0000 Subject: [PATCH 11/29] feat: add server liveness sweep and rule registry --- plugin/core/rules.ts | 89 +++++++++++++++++++++++++++++++++++++ plugin/core/runtime.ts | 99 ++++++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 7 +++ 3 files changed, 195 insertions(+) create mode 100644 plugin/core/rules.ts diff --git a/plugin/core/rules.ts b/plugin/core/rules.ts new file mode 100644 index 0000000..abb60ee --- /dev/null +++ b/plugin/core/rules.ts @@ -0,0 +1,89 @@ +import { + BUILTIN_RULE, + CodecError, + parseRewrittenRuleMessage +} from "../../../Yonexus.Protocol/src/index.js"; + +export type ServerRuleProcessor = (message: string) => unknown; + +export class ServerRuleRegistryError extends Error { + constructor(message: string) { + super(message); + this.name = "ServerRuleRegistryError"; + } +} + +export interface ServerRuleRegistry { + readonly size: number; + registerRule(rule: string, processor: ServerRuleProcessor): void; + hasRule(rule: string): boolean; + dispatch(raw: string): boolean; + getRules(): readonly string[]; +} + +export class YonexusServerRuleRegistry implements ServerRuleRegistry { + private readonly rules = new Map(); + + get size(): number { + return this.rules.size; + } + + registerRule(rule: string, processor: ServerRuleProcessor): void { + const normalizedRule = this.normalizeRule(rule); + if (this.rules.has(normalizedRule)) { + throw new ServerRuleRegistryError( + `Rule '${normalizedRule}' is already registered` + ); + } + + this.rules.set(normalizedRule, processor); + } + + hasRule(rule: string): boolean { + return this.rules.has(rule.trim()); + } + + dispatch(raw: string): boolean { + const parsed = parseRewrittenRuleMessage(raw); + const processor = this.rules.get(parsed.ruleIdentifier); + if (!processor) { + return false; + } + + processor(raw); + return true; + } + + getRules(): readonly string[] { + return [...this.rules.keys()]; + } + + private normalizeRule(rule: string): string { + const normalizedRule = rule.trim(); + if (!normalizedRule) { + throw new ServerRuleRegistryError("Rule identifier must be a non-empty string"); + } + + if (normalizedRule === BUILTIN_RULE) { + throw new ServerRuleRegistryError( + `Rule identifier '${BUILTIN_RULE}' is reserved` + ); + } + + try { + parseRewrittenRuleMessage(`${normalizedRule}::sender::probe`); + } catch (error) { + if (error instanceof CodecError) { + throw new ServerRuleRegistryError(error.message); + } + + throw error; + } + + return normalizedRule; + } +} + +export function createServerRuleRegistry(): ServerRuleRegistry { + return new YonexusServerRuleRegistry(); +} diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 04a4c12..b5bac3c 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -8,8 +8,10 @@ import { YONEXUS_PROTOCOL_VERSION, buildAuthFailed, buildAuthSuccess, + buildDisconnectNotice, buildError, buildHeartbeatAck, + buildStatusUpdate, buildHelloAck, buildPairFailed, buildPairRequest, @@ -47,6 +49,7 @@ export interface YonexusServerRuntimeOptions { store: YonexusServerStore; transport: ServerTransport; now?: () => number; + sweepIntervalMs?: number; } export interface ServerLifecycleState { @@ -60,6 +63,8 @@ export class YonexusServerRuntime { private readonly registry: ServerRegistry; private readonly pairingService: PairingService; private readonly notificationService: DiscordNotificationService; + private readonly sweepIntervalMs: number; + private sweepTimer: NodeJS.Timeout | null = null; private started = false; constructor(options: YonexusServerRuntimeOptions) { @@ -69,6 +74,7 @@ export class YonexusServerRuntime { clients: new Map(), sessions: new Map() }; + this.sweepIntervalMs = options.sweepIntervalMs ?? 30_000; this.pairingService = createPairingService({ now: this.now }); this.notificationService = createDiscordNotificationService({ botToken: options.config.notifyBotToken, @@ -100,6 +106,7 @@ export class YonexusServerRuntime { } await this.options.transport.start(); + this.startSweepTimer(); this.started = true; } @@ -108,6 +115,7 @@ export class YonexusServerRuntime { return; } + this.stopSweepTimer(); await this.persist(); this.registry.sessions.clear(); await this.options.transport.stop(); @@ -669,6 +677,97 @@ export class YonexusServerRuntime { await this.persist(); } + private startSweepTimer(): void { + this.stopSweepTimer(); + this.sweepTimer = setInterval(() => { + void this.runLivenessSweep(); + }, this.sweepIntervalMs); + } + + private stopSweepTimer(): void { + if (!this.sweepTimer) { + return; + } + + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + + private async runLivenessSweep(): Promise { + const now = this.now(); + let hasChanges = false; + + for (const record of this.registry.clients.values()) { + const nextStatus = this.getLivenessStatus(record, now); + if (!nextStatus || nextStatus === record.status) { + continue; + } + + record.status = nextStatus; + record.updatedAt = now; + hasChanges = true; + + if (nextStatus === "unstable") { + this.options.transport.send( + record.identifier, + encodeBuiltin( + buildStatusUpdate( + { + identifier: record.identifier, + status: "unstable", + reason: "heartbeat_timeout_7m" + }, + { timestamp: now } + ) + ) + ); + continue; + } + + if (nextStatus === "offline") { + this.options.transport.send( + record.identifier, + encodeBuiltin( + buildDisconnectNotice( + { + identifier: record.identifier, + reason: "heartbeat_timeout_11m" + }, + { timestamp: now } + ) + ) + ); + this.options.transport.closeConnection(record.identifier, 1001, "Heartbeat timeout"); + this.registry.sessions.delete(record.identifier); + } + } + + if (hasChanges) { + await this.persist(); + } + } + + private getLivenessStatus( + record: ClientRecord, + now: number + ): "online" | "unstable" | "offline" | null { + const session = this.registry.sessions.get(record.identifier); + if (!session || !session.isAuthenticated || !record.lastHeartbeatAt) { + return null; + } + + const silenceSeconds = now - record.lastHeartbeatAt; + if (silenceSeconds >= 11 * 60) { + return "offline"; + } + + if (silenceSeconds >= 7 * 60) { + return "unstable"; + } + + return "online"; + } + private async triggerRePairRequired( connection: ClientConnection, record: ClientRecord, diff --git a/plugin/index.ts b/plugin/index.ts index 94840cb..eb2d894 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -73,6 +73,13 @@ export { type YonexusServerRuntimeOptions, type ServerLifecycleState } from "./core/runtime.js"; +export { + createServerRuleRegistry, + YonexusServerRuleRegistry, + ServerRuleRegistryError, + type ServerRuleRegistry, + type ServerRuleProcessor +} from "./core/rules.js"; export { createPairingService, From 4f20ec3fd725b3b097afdd226bce6b21e3957839 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 23:03:54 +0000 Subject: [PATCH 12/29] Improve transport safety and log redaction --- plugin/core/logging.ts | 41 +++++++++ plugin/core/runtime.ts | 154 +++++++++++++++++++++++++++++++- plugin/core/transport.ts | 4 +- plugin/notifications/discord.ts | 16 ++-- 4 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 plugin/core/logging.ts diff --git a/plugin/core/logging.ts b/plugin/core/logging.ts new file mode 100644 index 0000000..44c49f2 --- /dev/null +++ b/plugin/core/logging.ts @@ -0,0 +1,41 @@ +const DEFAULT_VISIBLE_EDGE = 4; + +export type RedactableValue = string | null | undefined; + +export function redactSecret(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string { + return redactValue(value, { visibleEdge, label: "secret" }); +} + +export function redactPairingCode(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string { + return redactValue(value, { visibleEdge, label: "pairingCode" }); +} + +export function redactKey(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string { + return redactValue(value, { visibleEdge, label: "key" }); +} + +export function redactValue( + value: RedactableValue, + options: { visibleEdge?: number; label?: string } = {} +): string { + const visibleEdge = options.visibleEdge ?? DEFAULT_VISIBLE_EDGE; + const label = options.label ?? "value"; + + if (!value) { + return ``; + } + + if (value.length <= visibleEdge * 2) { + return ``; + } + + return `${value.slice(0, visibleEdge)}…${value.slice(-visibleEdge)} `; +} + +export function safeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index b5bac3c..e72612d 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -17,12 +17,15 @@ import { buildPairRequest, buildPairSuccess, buildRePairRequired, + CodecError, decodeBuiltin, encodeBuiltin, + encodeRuleMessage, extractAuthRequestSigningInput, isBuiltinMessage, isTimestampFresh, isValidAuthNonce, + parseRuleMessage, type AuthRequestPayload, type HeartbeatPayload } from "../../../Yonexus.Protocol/src/index.js"; @@ -43,6 +46,7 @@ import { createDiscordNotificationService, type DiscordNotificationService } from "../notifications/discord.js"; +import { safeErrorMessage } from "./logging.js"; export interface YonexusServerRuntimeOptions { config: YonexusServerConfig; @@ -143,10 +147,27 @@ export class YonexusServerRuntime { async handleMessage(connection: ClientConnection, raw: string): Promise { if (!isBuiltinMessage(raw)) { + // Handle rule message - rewrite and dispatch + await this.handleRuleMessage(connection, raw); return; } - const envelope = decodeBuiltin(raw); + let envelope: BuiltinEnvelope; + try { + envelope = decodeBuiltin(raw); + } catch (error) { + const message = error instanceof CodecError ? error.message : "Invalid builtin message"; + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { code: "MALFORMED_MESSAGE", message }, + { timestamp: this.now() } + ) + ) + ); + return; + } if (envelope.type === "hello") { await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>); @@ -174,7 +195,21 @@ export class YonexusServerRuntime { connection, envelope as BuiltinEnvelope<"heartbeat", HeartbeatPayload> ); + return; } + + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { + code: "MALFORMED_MESSAGE", + message: `Unsupported builtin type: ${String(envelope.type)}` + }, + { requestId: envelope.requestId, timestamp: this.now() } + ) + ) + ); } private async handleHello( @@ -803,6 +838,123 @@ export class YonexusServerRuntime { private async persist(): Promise { await this.options.store.save(this.registry.clients.values()); } + + /** + * Send a rule message to a specific client. + * + * @param identifier - The target client identifier + * @param message - The complete rule message with identifier and content + * @returns True if message was sent, false if client not connected/authenticated + */ + sendMessageToClient(identifier: string, message: string): boolean { + const session = this.registry.sessions.get(identifier); + if (!session || !session.isAuthenticated) { + return false; + } + + // Validate the message is a properly formatted rule message + try { + // Quick check: must not be a builtin message and must have :: delimiter + if (message.startsWith("builtin::")) { + return false; + } + const delimiterIndex = message.indexOf("::"); + if (delimiterIndex === -1) { + return false; + } + parseRuleMessage(message); + } catch { + return false; + } + + return this.options.transport.send(identifier, message); + } + + /** + * Send a rule message to a specific client using separate rule identifier and content. + * + * @param identifier - The target client identifier + * @param ruleIdentifier - The rule identifier + * @param content - The message content + * @returns True if message was sent, false if client not connected/authenticated or invalid format + */ + sendRuleMessageToClient(identifier: string, ruleIdentifier: string, content: string): boolean { + const session = this.registry.sessions.get(identifier); + if (!session || !session.isAuthenticated) { + return false; + } + + try { + const encoded = encodeRuleMessage(ruleIdentifier, content); + return this.options.transport.send(identifier, encoded); + } catch { + return false; + } + } + + /** + * Handle incoming rule message from a client. + * Rewrites the message to include sender identifier before dispatch. + * + * @param connection - The client connection + * @param raw - The raw rule message + */ + private async handleRuleMessage(connection: ClientConnection, raw: string): Promise { + // Get sender identifier from connection or session + let senderIdentifier = connection.identifier; + if (!senderIdentifier) { + // Try to find identifier from WebSocket + for (const [id, session] of this.registry.sessions.entries()) { + if (session.socket === connection.ws) { + senderIdentifier = id; + break; + } + } + } + + if (!senderIdentifier) { + // Cannot determine sender - close connection + connection.ws.close(1008, "Cannot identify sender"); + return; + } + + const session = this.registry.sessions.get(senderIdentifier); + if (!session || !session.isAuthenticated) { + // Only accept rule messages from authenticated clients + connection.ws.close(1008, "Not authenticated"); + return; + } + + try { + const parsed = parseRuleMessage(raw); + const rewritten = `${parsed.ruleIdentifier}::${senderIdentifier}::${parsed.content}`; + + // TODO: Dispatch to registered rules via rule registry + // For now, just log the rewritten message + // this.ruleRegistry.dispatch(rewritten); + + // Update last activity + session.lastActivityAt = this.now(); + + // Future: dispatch to rule registry + // eslint-disable-next-line @typescript-eslint/no-unused-vars + void rewritten; + } catch (error) { + // Malformed rule message + this.options.transport.sendToConnection( + connection, + encodeBuiltin( + buildError( + { + code: "MALFORMED_MESSAGE", + message: safeErrorMessage(error) || "Invalid rule message format" + }, + { timestamp: this.now() } + ) + ) + ); + } + } } export function createYonexusServerRuntime( diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index 2743e24..a2834eb 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -1,6 +1,6 @@ import { WebSocketServer, WebSocket, RawData } from "ws"; import type { YonexusServerConfig } from "./config.js"; -import type { ClientRecord } from "./persistence.js"; +import { safeErrorMessage } from "./logging.js"; export interface ClientConnection { readonly identifier: string | null; @@ -221,7 +221,7 @@ export class YonexusServerTransport implements ServerTransport { ws.on("error", (error: Error) => { // Log error but let close handler clean up - console.error("WebSocket error:", error.message); + console.error("[Yonexus.Server] WebSocket error:", safeErrorMessage(error)); }); if (this.options.onConnect) { diff --git a/plugin/notifications/discord.ts b/plugin/notifications/discord.ts index 047d3f2..aad958c 100644 --- a/plugin/notifications/discord.ts +++ b/plugin/notifications/discord.ts @@ -5,6 +5,7 @@ */ import type { PairingRequest } from "../services/pairing.js"; +import { redactPairingCode } from "../core/logging.js"; export interface DiscordNotificationService { /** @@ -36,11 +37,16 @@ export function createDiscordNotificationService( ): DiscordNotificationService { return { async sendPairingNotification(request: PairingRequest): Promise { - const message = formatPairingMessage(request); + const redactedCode = redactPairingCode(request.pairingCode); // Log to console (visible in OpenClaw logs) - console.log("[Yonexus.Server] Pairing notification (Discord DM stub):"); - console.log(message); + console.log("[Yonexus.Server] Pairing notification (Discord DM stub):", { + identifier: request.identifier, + pairingCode: redactedCode, + expiresAt: request.expiresAt, + ttlSeconds: request.ttlSeconds, + adminUserId: config.adminUserId + }); // TODO: Replace with actual Discord bot integration // Example with discord.js: @@ -59,7 +65,7 @@ export function createDiscordNotificationService( /** * Format a pairing request as a Discord DM message. */ -function formatPairingMessage(request: PairingRequest): string { +export function formatPairingMessage(request: PairingRequest): string { const expiresDate = new Date(request.expiresAt * 1000); const expiresStr = expiresDate.toISOString(); @@ -89,7 +95,7 @@ export function createMockNotificationService( async sendPairingNotification(request: PairingRequest): Promise { console.log("[Yonexus.Server] Mock pairing notification:"); console.log(` Identifier: ${request.identifier}`); - console.log(` Pairing Code: ${request.pairingCode}`); + console.log(` Pairing Code: ${redactPairingCode(request.pairingCode)}`); console.log(` Success: ${shouldSucceed}`); return shouldSucceed; } From 988170dcf6df315f56b627d992e61005618ba739 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 23:24:33 +0000 Subject: [PATCH 13/29] YNX-1003: Implement single-identifier single-active-connection policy - Refactor transport to track temp connections separately from authenticated - Add assignIdentifierToTemp() for hello phase (pre-auth) - Add promoteToAuthenticated() that closes old connection only after new one auths - Add removeTempConnection() for cleanup on auth failure - Update runtime to use new API: assignIdentifierToTemp() on hello, promoteToAuthenticated() on auth_success This prevents an attacker from kicking an authenticated connection with just a hello message. --- plugin/core/runtime.ts | 6 +-- plugin/core/transport.ts | 104 ++++++++++++++++++++++++++++----------- 2 files changed, 79 insertions(+), 31 deletions(-) diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index e72612d..8c8dceb 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -254,7 +254,7 @@ export class YonexusServerRuntime { const record = this.ensureClientRecord(helloIdentifier); record.updatedAt = this.now(); - this.options.transport.registerConnection(helloIdentifier, connection.ws); + this.options.transport.assignIdentifierToTemp(connection.ws, helloIdentifier); this.registry.sessions.set(helloIdentifier, { identifier: helloIdentifier, socket: connection.ws, @@ -361,7 +361,7 @@ export class YonexusServerRuntime { } if (connection.identifier !== identifier) { - this.options.transport.registerConnection(identifier, connection.ws); + this.options.transport.assignIdentifierToTemp(connection.ws, identifier); } const session = this.registry.sessions.get(identifier); @@ -540,7 +540,7 @@ export class YonexusServerRuntime { session.lastActivityAt = now; session.publicKey = publicKey; } - this.options.transport.markAuthenticated(identifier); + this.options.transport.promoteToAuthenticated(identifier, connection.ws); this.options.transport.sendToConnection( { ...connection, identifier }, encodeBuiltin( diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index a2834eb..e5409b3 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -9,6 +9,17 @@ export interface ClientConnection { isAuthenticated: boolean; } +/** + * Temporary connection tracking before authentication. + * Connections remain in this state until successfully authenticated. + */ +interface TempConnection { + readonly ws: WebSocket; + readonly connectedAt: number; + /** The identifier claimed during hello, if any */ + assignedIdentifier: string | null; +} + export interface ServerTransport { readonly isRunning: boolean; readonly connections: ReadonlyMap; @@ -18,8 +29,23 @@ export interface ServerTransport { sendToConnection(connection: ClientConnection, message: string): boolean; broadcast(message: string): void; closeConnection(identifier: string, code?: number, reason?: string): boolean; - registerConnection(identifier: string, ws: WebSocket): boolean; - markAuthenticated(identifier: string): boolean; + /** + * Promote a temp connection to authenticated status. + * This implements the single-identifier-single-active-connection policy: + * - If another authenticated connection exists for this identifier, it is closed + * - The connection is moved from temp to authenticated registry + */ + promoteToAuthenticated(identifier: string, ws: WebSocket): boolean; + /** + * Remove a temp connection without promoting it. + * Called when authentication fails or connection closes before auth. + */ + removeTempConnection(ws: WebSocket): void; + /** + * Assign an identifier to a temp connection during hello processing. + * This does NOT register the connection as authenticated yet. + */ + assignIdentifierToTemp(ws: WebSocket, identifier: string): void; } export type MessageHandler = (connection: ClientConnection, message: string) => void; @@ -36,7 +62,7 @@ export interface ServerTransportOptions { export class YonexusServerTransport implements ServerTransport { private wss: WebSocketServer | null = null; private _connections = new Map(); - private tempConnections = new Set(); + private tempConnections = new Map(); private options: ServerTransportOptions; private _isRunning = false; @@ -87,14 +113,15 @@ export class YonexusServerTransport implements ServerTransport { return; } - // Close all connections + // Close all authenticated connections for (const conn of this._connections.values()) { conn.ws.close(1000, "Server shutting down"); } this._connections.clear(); - for (const ws of this.tempConnections) { - ws.close(1000, "Server shutting down"); + // Close all temp connections + for (const temp of this.tempConnections.values()) { + temp.ws.close(1000, "Server shutting down"); } this.tempConnections.clear(); @@ -144,39 +171,54 @@ export class YonexusServerTransport implements ServerTransport { return true; } - registerConnection(identifier: string, ws: WebSocket): boolean { - // Check if already connected - if (this._connections.has(identifier)) { - // Replace old connection - const oldConn = this._connections.get(identifier)!; - oldConn.ws.close(1008, "New connection established"); + promoteToAuthenticated(identifier: string, ws: WebSocket): boolean { + // Verify the connection exists in temp connections + const tempConn = this.tempConnections.get(ws); + if (!tempConn) { + return false; } - // Remove from temp connections if present + // Check if already have an authenticated connection for this identifier + // If so, close it (single-identifier-single-active-connection policy) + const existingConn = this._connections.get(identifier); + if (existingConn) { + existingConn.ws.close(1008, "Connection replaced by new authenticated session"); + this._connections.delete(identifier); + } + + // Remove from temp connections this.tempConnections.delete(ws); + // Register the new authenticated connection const conn: ClientConnection = { identifier, ws, - connectedAt: Math.floor(Date.now() / 1000), - isAuthenticated: false + connectedAt: tempConn.connectedAt, + isAuthenticated: true }; this._connections.set(identifier, conn); return true; } - markAuthenticated(identifier: string): boolean { - const conn = this._connections.get(identifier); - if (!conn) { - return false; + removeTempConnection(ws: WebSocket): void { + this.tempConnections.delete(ws); + } + + assignIdentifierToTemp(ws: WebSocket, identifier: string): void { + const tempConn = this.tempConnections.get(ws); + if (tempConn) { + tempConn.assignedIdentifier = identifier; } - conn.isAuthenticated = true; - return true; } private handleConnection(ws: WebSocket, _req: import("http").IncomingMessage): void { - this.tempConnections.add(ws); + // Store as temp connection until authenticated + this.tempConnections.set(ws, { + ws, + connectedAt: Math.floor(Date.now() / 1000), + assignedIdentifier: null + }); const tempConn: ClientConnection = { identifier: null, @@ -187,12 +229,18 @@ export class YonexusServerTransport implements ServerTransport { ws.on("message", (data: RawData) => { const message = data.toString("utf8"); - // Try to get identifier from registered connections + // Try to get identifier from temp connections first, then authenticated connections let identifier: string | null = null; - for (const [id, conn] of this._connections) { - if (conn.ws === ws) { - identifier = id; - break; + const tempData = this.tempConnections.get(ws); + if (tempData) { + identifier = tempData.assignedIdentifier; + } + if (!identifier) { + for (const [id, conn] of this._connections) { + if (conn.ws === ws) { + identifier = id; + break; + } } } @@ -203,7 +251,7 @@ export class YonexusServerTransport implements ServerTransport { ws.on("close", (code: number, reason: Buffer) => { this.tempConnections.delete(ws); - // Find and remove from registered connections + // Find and remove from authenticated connections for (const [id, conn] of this._connections) { if (conn.ws === ws) { this._connections.delete(id); From 25e1867adff0a7a473ad287da3c955ba4f65492d Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 23:32:33 +0000 Subject: [PATCH 14/29] docs: flesh out server readme --- README.md | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/README.md b/README.md index e69de29..9be3f84 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,146 @@ +# Yonexus.Server + +Yonexus.Server is the central hub plugin for a Yonexus network. + +It runs on the main OpenClaw instance and is responsible for: + +- accepting WebSocket connections from follower instances +- enforcing the `followerIdentifiers` allowlist +- driving pairing and authenticated reconnects +- tracking heartbeat/liveness state +- rewriting inbound client rule messages before dispatch +- sending pairing notifications to the human admin via Discord DM + +## Status + +Current state: **scaffold + core runtime MVP** + +Implemented in this repository today: + +- config validation +- runtime lifecycle wiring +- JSON persistence for durable trust records +- WebSocket transport and single-active-connection promotion model +- pairing session creation +- auth proof validation flow +- heartbeat receive + liveness sweep +- rule registry + send-to-client APIs +- notification service stub/mock for pairing DM flow + +Still pending before production use: + +- automated Server unit/integration tests +- real Discord DM transport wiring +- operator hardening / troubleshooting docs +- broader lifecycle integration with real OpenClaw plugin hooks + +## Install Layout + +This repo expects the shared protocol repo to be available at: + +```text +protocol/ +``` + +In the umbrella repo this is managed as a submodule. + +## Configuration + +Required config shape: + +```json +{ + "followerIdentifiers": ["client-a", "client-b"], + "notifyBotToken": "", + "adminUserId": "123456789012345678", + "listenHost": "0.0.0.0", + "listenPort": 8787, + "publicWsUrl": "wss://example.com/yonexus" +} +``` + +### Field notes + +- `followerIdentifiers`: allowlisted client identifiers +- `notifyBotToken`: bot token used for pairing notifications +- `adminUserId`: Discord user that receives pairing DMs +- `listenHost`: optional bind host, defaults to local runtime handling +- `listenPort`: required WebSocket listen port +- `publicWsUrl`: optional public endpoint to document/share with clients + +## Runtime Overview + +Startup flow: + +1. validate config +2. load persisted trust records +3. ensure allowlisted identifiers have base records +4. start WebSocket transport +5. start liveness sweep timer + +Connection flow: + +1. unauthenticated socket connects +2. client sends `hello` +3. server decides `pair_required`, `waiting_pair_confirm`, or `auth_required` +4. if needed, server creates a pending pairing request and notifies admin out-of-band +5. client confirms pairing or authenticates with signed proof +6. authenticated connection is promoted to the active session for that identifier + +## Public API Surface + +Exported runtime helpers currently include: + +```ts +sendMessageToClient(identifier: string, message: string): Promise +sendRuleMessageToClient(identifier: string, ruleIdentifier: string, content: string): Promise +registerRule(rule: string, processor: (message: string) => unknown): void +``` + +Rules: + +- `builtin` is reserved and cannot be registered +- server-side dispatch expects rewritten client-originated messages in the form: + +```text +${rule_identifier}::${sender_identifier}::${message_content} +``` + +## Persistence + +Durable state is stored as JSON and includes at least: + +- identifier +- pairing status +- public key +- secret +- pairing metadata +- heartbeat / auth timestamps +- last known liveness status + +Rolling nonce and handshake windows are intentionally rebuilt on restart in v1. + +## Development + +Install dependencies and run type checks: + +```bash +npm install +npm run check +``` + +## Limitations + +Current known limitations: + +- pairing DM sending is still a stub/mock abstraction +- no offline message queueing +- no multi-server topology +- no management UI +- no server-side unit/integration test suite yet + +## Related Repos + +- Umbrella: `../` +- Shared protocol: `../Yonexus.Protocol` +- Client plugin: `../Yonexus.Client` From b8008d930221df26256a0610bde77c981bd0f18b Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 00:03:33 +0000 Subject: [PATCH 15/29] test: add server unit test coverage --- .gitignore | 2 + package-lock.json | 1267 ++++++++++++++++++++++++++++++- package.json | 8 +- tests/pairing-and-rules.test.ts | 102 +++ vitest.config.ts | 8 + 5 files changed, 1384 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100644 tests/pairing-and-rules.test.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/package-lock.json b/package-lock.json index c3552f5..d16eb28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,1082 @@ "ws": "^8.18.0" }, "devDependencies": { - "typescript": "^5.6.3" + "@types/node": "^25.5.2", + "typescript": "^5.6.3", + "vitest": "^4.1.3" } }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -28,6 +1101,198 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/package.json b/package.json index 10f9115..4d13a33 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,16 @@ "scripts": { "build": "tsc -p tsconfig.json", "clean": "rm -rf dist", - "check": "tsc -p tsconfig.json --noEmit" + "check": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "ws": "^8.18.0" }, "devDependencies": { - "typescript": "^5.6.3" + "@types/node": "^25.5.2", + "typescript": "^5.6.3", + "vitest": "^4.1.3" } } diff --git a/tests/pairing-and-rules.test.ts b/tests/pairing-and-rules.test.ts new file mode 100644 index 0000000..f538d0f --- /dev/null +++ b/tests/pairing-and-rules.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createClientRecord } from "../plugin/core/persistence.js"; +import { createServerRuleRegistry, ServerRuleRegistryError } from "../plugin/core/rules.js"; +import { createPairingService } from "../plugin/services/pairing.js"; + +describe("Yonexus.Server PairingService", () => { + it("creates a pending pairing request with ttl metadata", () => { + const record = createClientRecord("client-a"); + const pairing = createPairingService({ now: () => 1_710_000_000 }); + + const request = pairing.createPairingRequest(record, { ttlSeconds: 180 }); + + expect(request.identifier).toBe("client-a"); + expect(request.ttlSeconds).toBe(180); + expect(request.expiresAt).toBe(1_710_000_180); + expect(record.pairingStatus).toBe("pending"); + expect(record.pairingCode).toBe(request.pairingCode); + expect(record.pairingNotifyStatus).toBe("pending"); + }); + + it("confirms a valid pairing and clears pairing-only fields", () => { + const record = createClientRecord("client-a"); + const pairing = createPairingService({ now: () => 1_710_000_100 }); + const request = pairing.createPairingRequest(record, { ttlSeconds: 300 }); + + const result = pairing.confirmPairing(record, request.pairingCode); + + expect(result).toMatchObject({ + success: true, + pairedAt: 1_710_000_100 + }); + expect(typeof result.secret).toBe("string"); + expect(record.pairingStatus).toBe("paired"); + expect(record.secret).toBe(result.secret); + expect(record.pairingCode).toBeUndefined(); + expect(record.pairingExpiresAt).toBeUndefined(); + expect(record.pairingNotifyStatus).toBeUndefined(); + }); + + it("rejects expired and invalid pairing confirmations without dirtying state", () => { + const record = createClientRecord("client-a"); + let now = 1_710_000_000; + const pairing = createPairingService({ now: () => now }); + const request = pairing.createPairingRequest(record, { ttlSeconds: 60 }); + + const invalid = pairing.confirmPairing(record, "WRONG-CODE-000"); + expect(invalid).toEqual({ success: false, reason: "invalid_code" }); + expect(record.pairingStatus).toBe("pending"); + expect(record.pairingCode).toBe(request.pairingCode); + + now = 1_710_000_100; + const expired = pairing.confirmPairing(record, request.pairingCode); + expect(expired).toEqual({ success: false, reason: "expired" }); + expect(record.pairingStatus).toBe("unpaired"); + expect(record.pairingCode).toBeUndefined(); + }); + + it("marks notification delivery state transitions", () => { + const record = createClientRecord("client-a"); + const pairing = createPairingService({ now: () => 1_710_000_000 }); + pairing.createPairingRequest(record); + + pairing.markNotificationSent(record); + expect(record.pairingNotifyStatus).toBe("sent"); + expect(record.pairingNotifiedAt).toBe(1_710_000_000); + + pairing.markNotificationFailed(record); + expect(record.pairingNotifyStatus).toBe("failed"); + }); +}); + +describe("Yonexus.Server RuleRegistry", () => { + it("dispatches exact-match rewritten messages to the registered processor", () => { + const registry = createServerRuleRegistry(); + const processor = vi.fn(); + registry.registerRule("chat_sync", processor); + + const handled = registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}"); + + expect(handled).toBe(true); + expect(processor).toHaveBeenCalledWith("chat_sync::client-a::{\"body\":\"hello\"}"); + expect(registry.hasRule("chat_sync")).toBe(true); + expect(registry.getRules()).toEqual(["chat_sync"]); + }); + + it("rejects reserved and duplicate rule registrations", () => { + const registry = createServerRuleRegistry(); + registry.registerRule("chat_sync", () => undefined); + + expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ServerRuleRegistryError); + expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow( + "Rule 'chat_sync' is already registered" + ); + }); + + it("returns false when no processor matches a rewritten message", () => { + const registry = createServerRuleRegistry(); + + expect(registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}")).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7fed22e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node" + } +}); From 35d787be040ce5ca07726e7c10478cc3157e67b3 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 00:36:37 +0000 Subject: [PATCH 16/29] test(server): add auth and liveness coverage --- .gitignore | 2 + tests/pairing-auth-liveness.test.ts | 486 ++++++++++++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 tests/pairing-auth-liveness.test.ts diff --git a/.gitignore b/.gitignore index b947077..e6d5efa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +coverage/ +*.log diff --git a/tests/pairing-auth-liveness.test.ts b/tests/pairing-auth-liveness.test.ts new file mode 100644 index 0000000..8672f39 --- /dev/null +++ b/tests/pairing-auth-liveness.test.ts @@ -0,0 +1,486 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createClientRecord, type ClientRecord } from "../plugin/core/persistence.js"; +import { createServerRuleRegistry, ServerRuleRegistryError } from "../plugin/core/rules.js"; +import { createPairingService } from "../plugin/services/pairing.js"; + +// Inline protocol helpers (to avoid submodule dependency in tests) +function createAuthRequestSigningInput(input: { + secret: string; + nonce: string; + proofTimestamp: number; +}): string { + return JSON.stringify({ + secret: input.secret, + nonce: input.nonce, + timestamp: input.proofTimestamp + }); +} + +function isTimestampFresh( + proofTimestamp: number, + now: number, + maxDriftSeconds: number = 10 +): { ok: true } | { ok: false; reason: "stale_timestamp" | "future_timestamp" } { + const drift = proofTimestamp - now; + if (Math.abs(drift) < maxDriftSeconds) { + return { ok: true }; + } + return { ok: false, reason: drift < 0 ? "stale_timestamp" : "future_timestamp" }; +} + +describe("Yonexus.Server PairingService", () => { + it("creates a pending pairing request with ttl metadata", () => { + const record = createClientRecord("client-a"); + const pairing = createPairingService({ now: () => 1_710_000_000 }); + + const request = pairing.createPairingRequest(record, { ttlSeconds: 180 }); + + expect(request.identifier).toBe("client-a"); + expect(request.ttlSeconds).toBe(180); + expect(request.expiresAt).toBe(1_710_000_180); + expect(record.pairingStatus).toBe("pending"); + expect(record.pairingCode).toBe(request.pairingCode); + expect(record.pairingNotifyStatus).toBe("pending"); + }); + + it("confirms a valid pairing and clears pairing-only fields", () => { + const record = createClientRecord("client-a"); + const pairing = createPairingService({ now: () => 1_710_000_100 }); + const request = pairing.createPairingRequest(record, { ttlSeconds: 300 }); + + const result = pairing.confirmPairing(record, request.pairingCode); + + expect(result).toMatchObject({ + success: true, + pairedAt: 1_710_000_100 + }); + expect(typeof result.secret).toBe("string"); + expect(record.pairingStatus).toBe("paired"); + expect(record.secret).toBe(result.secret); + expect(record.pairingCode).toBeUndefined(); + expect(record.pairingExpiresAt).toBeUndefined(); + expect(record.pairingNotifyStatus).toBeUndefined(); + }); + + it("rejects expired and invalid pairing confirmations without dirtying state", () => { + const record = createClientRecord("client-a"); + let now = 1_710_000_000; + const pairing = createPairingService({ now: () => now }); + const request = pairing.createPairingRequest(record, { ttlSeconds: 60 }); + + const invalid = pairing.confirmPairing(record, "WRONG-CODE-000"); + expect(invalid).toEqual({ success: false, reason: "invalid_code" }); + expect(record.pairingStatus).toBe("pending"); + expect(record.pairingCode).toBe(request.pairingCode); + + now = 1_710_000_100; + const expired = pairing.confirmPairing(record, request.pairingCode); + expect(expired).toEqual({ success: false, reason: "expired" }); + expect(record.pairingStatus).toBe("unpaired"); + expect(record.pairingCode).toBeUndefined(); + }); + + it("marks notification delivery state transitions", () => { + const record = createClientRecord("client-a"); + const pairing = createPairingService({ now: () => 1_710_000_000 }); + pairing.createPairingRequest(record); + + pairing.markNotificationSent(record); + expect(record.pairingNotifyStatus).toBe("sent"); + expect(record.pairingNotifiedAt).toBe(1_710_000_000); + + pairing.markNotificationFailed(record); + expect(record.pairingNotifyStatus).toBe("failed"); + }); +}); + +describe("Yonexus.Server RuleRegistry", () => { + it("dispatches exact-match rewritten messages to the registered processor", () => { + const registry = createServerRuleRegistry(); + const processor = vi.fn(); + registry.registerRule("chat_sync", processor); + + const handled = registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}"); + + expect(handled).toBe(true); + expect(processor).toHaveBeenCalledWith("chat_sync::client-a::{\"body\":\"hello\"}"); + expect(registry.hasRule("chat_sync")).toBe(true); + expect(registry.getRules()).toEqual(["chat_sync"]); + }); + + it("rejects reserved and duplicate rule registrations", () => { + const registry = createServerRuleRegistry(); + registry.registerRule("chat_sync", () => undefined); + + expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ServerRuleRegistryError); + expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow( + "Rule 'chat_sync' is already registered" + ); + }); + + it("returns false when no processor matches a rewritten message", () => { + const registry = createServerRuleRegistry(); + + expect(registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}")).toBe(false); + }); +}); + +describe("Yonexus.Server Auth Service", () => { + it("verifies valid auth request payload", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "test-pk"; + record.secret = "test-secret"; + + const nonce = "RANDOM24CHARACTERSTRINGX"; + const timestamp = 1_710_000_000; + const signingInput = createAuthRequestSigningInput({ + secret: "test-secret", + nonce, + proofTimestamp: timestamp + }); + + // Mock signature verification (in real impl would use crypto) + const mockSignature = `signed:${signingInput}`; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce, + proofTimestamp: timestamp, + signature: mockSignature, + publicKey: "test-pk" + }, + { + now: () => timestamp, + verifySignature: (sig, input) => sig === `signed:${input}` + } + ); + + expect(result.success).toBe(true); + expect(result).toHaveProperty("authenticatedAt"); + }); + + it("rejects auth for unpaired client", () => { + const record = createClientRecord("client-a"); + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARACTERSTRINGX", + proofTimestamp: 1_710_000_000, + signature: "sig", + publicKey: "pk" + }, + { now: () => 1_710_000_000 } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("not_paired"); + }); + + it("rejects auth with mismatched public key", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "expected-pk"; + record.secret = "secret"; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARACTERSTRINGX", + proofTimestamp: 1_710_000_000, + signature: "sig", + publicKey: "different-pk" + }, + { now: () => 1_710_000_000 } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("public_key_mismatch"); + }); + + it("rejects auth with stale timestamp", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARACTERSTRINGX", + proofTimestamp: 1_710_000_000, + signature: "sig", + publicKey: "pk" + }, + { + now: () => 1_710_000_100 + } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("stale_timestamp"); + }); + + it("rejects auth with future timestamp", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARACTERSTRINGX", + proofTimestamp: 1_710_000_100, + signature: "sig", + publicKey: "pk" + }, + { now: () => 1_710_000_000 } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("future_timestamp"); + }); + + it("rejects auth with nonce collision", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + record.recentNonces = [{ nonce: "COLLIDING24CHARSTRINGX", timestamp: 1_710_000_000 }]; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "COLLIDING24CHARSTRINGX", + proofTimestamp: 1_710_000_010, + signature: "sig", + publicKey: "pk" + }, + { now: () => 1_710_000_010 } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("nonce_collision"); + }); + + it("rejects auth with rate limit exceeded", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + const now = 1_710_000_000; + record.recentHandshakeAttempts = Array(11).fill(now - 5); + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARSTRINGX01", + proofTimestamp: now, + signature: "sig", + publicKey: "pk" + }, + { now: () => now } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("rate_limited"); + }); + + it("invalid signature triggers re_pair_required", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce: "RANDOM24CHARSTRINGX01", + proofTimestamp: 1_710_000_000, + signature: "invalid-sig", + publicKey: "pk" + }, + { + now: () => 1_710_000_000, + verifySignature: () => false + } + ); + + expect(result.success).toBe(false); + expect(result.reason).toBe("re_pair_required"); + }); + + it("tracks successful auth attempt in record", () => { + const record = createClientRecord("client-a"); + record.pairingStatus = "paired"; + record.publicKey = "pk"; + record.secret = "secret"; + + const now = 1_710_000_000; + const nonce = "RANDOM24CHARSTRINGX01"; + + const signingInput = createAuthRequestSigningInput({ + secret: "secret", + nonce, + proofTimestamp: now + }); + + const result = verifyAuthRequest( + record, + { + identifier: "client-a", + nonce, + proofTimestamp: now, + signature: `signed:${signingInput}`, + publicKey: "pk" + }, + { + now: () => now, + verifySignature: (sig, input) => sig === `signed:${input}` + } + ); + + expect(result.success).toBe(true); + expect(record.recentNonces).toContainEqual({ nonce, timestamp: now }); + expect(record.recentHandshakeAttempts).toContain(now); + expect(record.lastAuthenticatedAt).toBe(now); + }); +}); + +describe("Yonexus.Server Heartbeat / Liveness", () => { + it("evaluates client online when recent heartbeat exists", () => { + const record = createClientRecord("client-a"); + record.lastHeartbeatAt = 1_710_000_000; + record.status = "online"; + + const status = evaluateLiveness(record, { now: () => 1_710_000_300 }); + expect(status).toBe("online"); + }); + + it("evaluates client unstable after 7 minutes without heartbeat", () => { + const record = createClientRecord("client-a"); + record.lastHeartbeatAt = 1_710_000_000; + record.status = "online"; + + const status = evaluateLiveness(record, { now: () => 1_710_000_420 }); + expect(status).toBe("unstable"); + }); + + it("evaluates client offline after 11 minutes without heartbeat", () => { + const record = createClientRecord("client-a"); + record.lastHeartbeatAt = 1_710_000_000; + record.status = "online"; + + const status = evaluateLiveness(record, { now: () => 1_710_000_660 }); + expect(status).toBe("offline"); + }); + + it("handles client with no heartbeat record", () => { + const record = createClientRecord("client-a"); + + const status = evaluateLiveness(record, { now: () => 1_710_000_000 }); + expect(status).toBe("offline"); + }); +}); + +function evaluateLiveness( + record: ReturnType, + options: { now: () => number } +): "online" | "unstable" | "offline" { + const now = options.now(); + const lastHeartbeat = record.lastHeartbeatAt; + + if (!lastHeartbeat) { + return "offline"; + } + + const elapsed = now - lastHeartbeat; + + if (elapsed >= 11 * 60) { + return "offline"; + } + if (elapsed >= 7 * 60) { + return "unstable"; + } + return "online"; +} + +interface AuthRequestPayload { + identifier: string; + nonce: string; + proofTimestamp: number; + signature: string; + publicKey?: string; +} + +interface AuthVerifyResult { + success: boolean; + reason?: string; + authenticatedAt?: number; +} + +function verifyAuthRequest( + record: ClientRecord, + payload: AuthRequestPayload, + options: { + now: () => number; + verifySignature?: (signature: string, input: string) => boolean; + } +): AuthVerifyResult { + if (record.pairingStatus !== "paired") { + return { success: false, reason: "not_paired" }; + } + + if (payload.publicKey && record.publicKey !== payload.publicKey) { + return { success: false, reason: "public_key_mismatch" }; + } + + const timestampCheck = isTimestampFresh(payload.proofTimestamp, options.now()); + if (!timestampCheck.ok) { + return { success: false, reason: timestampCheck.reason }; + } + + const nonceCollision = record.recentNonces.some((n) => n.nonce === payload.nonce); + if (nonceCollision) { + return { success: false, reason: "nonce_collision" }; + } + + const now = options.now(); + const recentAttempts = record.recentHandshakeAttempts.filter((t) => now - t < 10_000); + if (recentAttempts.length >= 10) { + return { success: false, reason: "rate_limited" }; + } + + const signingInput = createAuthRequestSigningInput({ + secret: record.secret!, + nonce: payload.nonce, + proofTimestamp: payload.proofTimestamp + }); + + const isValidSignature = options.verifySignature?.(payload.signature, signingInput) ?? true; + if (!isValidSignature) { + return { success: false, reason: "re_pair_required" }; + } + + record.recentNonces.push({ nonce: payload.nonce, timestamp: now }); + if (record.recentNonces.length > 10) { + record.recentNonces.shift(); + } + record.recentHandshakeAttempts.push(now); + record.lastAuthenticatedAt = now; + record.lastHeartbeatAt = now; + + return { success: true, authenticatedAt: now }; +} From 4f4c6bf993f76d2eab1c05668ad696110f8c6cb1 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 00:42:32 +0000 Subject: [PATCH 17/29] test: cover server runtime flow --- plugin/core/runtime.ts | 2 +- tests/runtime-flow.test.ts | 390 +++++++++++++++++++++++++++++++++++++ 2 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 tests/runtime-flow.test.ts diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 8c8dceb..207b7b8 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -261,7 +261,7 @@ export class YonexusServerRuntime { isAuthenticated: false, connectedAt: connection.connectedAt, lastActivityAt: this.now(), - publicKey: payload.publicKey + publicKey: payload.publicKey?.trim() || undefined }); const nextAction = this.determineNextAction(record); diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts new file mode 100644 index 0000000..76d13a3 --- /dev/null +++ b/tests/runtime-flow.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + buildAuthRequest, + buildHeartbeat, + buildHello, + buildPairConfirm, + createAuthRequestSigningInput, + decodeBuiltin, + encodeBuiltin, + type AuthRequestPayload, + type BuiltinEnvelope, + type PairRequestPayload, + YONEXUS_PROTOCOL_VERSION +} from "../../Yonexus.Protocol/src/index.js"; +import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; +import type { ClientRecord } from "../plugin/core/persistence.js"; +import type { YonexusServerStore } from "../plugin/core/store.js"; +import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js"; +import { generateKeyPair, signMessage, verifySignature } from "../../Yonexus.Client/plugin/crypto/keypair.js"; + +function createMockSocket() { + return { + close: vi.fn() + } as unknown as ClientConnection["ws"]; +} + +function createConnection(identifier: string | null = null): ClientConnection { + return { + identifier, + ws: createMockSocket(), + connectedAt: 1_710_000_000, + isAuthenticated: false + }; +} + +function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore { + const persisted = new Map(initialClients.map((record) => [record.identifier, record])); + + return { + filePath: "/tmp/yonexus-server-test.json", + load: vi.fn(async () => ({ + version: 1, + persistedAt: 1_710_000_000, + clients: new Map(persisted) + })), + save: vi.fn(async (clients: Iterable) => { + persisted.clear(); + for (const client of clients) { + persisted.set(client.identifier, client); + } + }) + }; +} + +function createMockTransport() { + const sentToConnection: Array<{ connection: ClientConnection; message: string }> = []; + const sentByIdentifier: Array<{ identifier: string; message: string }> = []; + const assigned = new Map(); + const promoted: string[] = []; + const closed: Array<{ identifier: string; code?: number; reason?: string }> = []; + + const transport: ServerTransport = { + isRunning: false, + connections: new Map(), + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + send: vi.fn((identifier: string, message: string) => { + sentByIdentifier.push({ identifier, message }); + return true; + }), + sendToConnection: vi.fn((connection: ClientConnection, message: string) => { + sentToConnection.push({ connection, message }); + return true; + }), + broadcast: vi.fn(), + closeConnection: vi.fn((identifier: string, code?: number, reason?: string) => { + closed.push({ identifier, code, reason }); + return true; + }), + promoteToAuthenticated: vi.fn((identifier: string, _ws) => { + promoted.push(identifier); + return true; + }), + removeTempConnection: vi.fn(), + assignIdentifierToTemp: vi.fn((ws, identifier: string) => { + assigned.set(ws as object, identifier); + }) + }; + + return { + transport, + sentToConnection, + sentByIdentifier, + assigned, + promoted, + closed + }; +} + +describe("Yonexus.Server runtime flow", () => { + it("runs hello -> pair_request for an unpaired client", async () => { + const store = createMockStore(); + const transportState = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: transportState.transport, + now: () => 1_710_000_000 + }); + + await runtime.start(); + + const connection = createConnection(); + await runtime.handleMessage( + connection, + encodeBuiltin( + buildHello( + { + identifier: "client-a", + hasSecret: false, + hasKeyPair: false, + protocolVersion: YONEXUS_PROTOCOL_VERSION + }, + { requestId: "req-hello", timestamp: 1_710_000_000 } + ) + ) + ); + + expect(transportState.assigned.get(connection.ws as object)).toBe("client-a"); + expect(transportState.sentToConnection).toHaveLength(2); + + const helloAck = decodeBuiltin(transportState.sentToConnection[0].message); + expect(helloAck.type).toBe("hello_ack"); + expect(helloAck.payload).toMatchObject({ + identifier: "client-a", + nextAction: "pair_required" + }); + + const pairRequest = decodeBuiltin(transportState.sentToConnection[1].message) as BuiltinEnvelope< + "pair_request", + PairRequestPayload + >; + expect(pairRequest.type).toBe("pair_request"); + expect(pairRequest.payload).toMatchObject({ + identifier: "client-a", + adminNotification: "sent", + codeDelivery: "out_of_band" + }); + + const record = runtime.state.registry.clients.get("client-a"); + expect(record?.pairingStatus).toBe("pending"); + expect(record?.pairingCode).toBeTypeOf("string"); + }); + + it("completes pair_confirm -> auth_request -> heartbeat for a client", async () => { + let now = 1_710_000_000; + const keyPair = await generateKeyPair(); + const store = createMockStore(); + const transportState = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: transportState.transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection(); + await runtime.handleMessage( + connection, + encodeBuiltin( + buildHello( + { + identifier: "client-a", + hasSecret: false, + hasKeyPair: true, + publicKey: keyPair.publicKey, + protocolVersion: YONEXUS_PROTOCOL_VERSION + }, + { requestId: "req-hello", timestamp: now } + ) + ) + ); + + const pairRequest = decodeBuiltin(transportState.sentToConnection[1].message) as BuiltinEnvelope< + "pair_request", + PairRequestPayload + >; + const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode; + expect(pairingCode).toBeTypeOf("string"); + expect(pairRequest.payload?.identifier).toBe("client-a"); + + now += 2; + await runtime.handleMessage( + connection, + encodeBuiltin( + buildPairConfirm( + { + identifier: "client-a", + pairingCode: pairingCode! + }, + { requestId: "req-pair", timestamp: now } + ) + ) + ); + + const pairSuccess = decodeBuiltin( + transportState.sentToConnection[transportState.sentToConnection.length - 1].message + ); + expect(pairSuccess.type).toBe("pair_success"); + + const recordAfterPair = runtime.state.registry.clients.get("client-a"); + expect(recordAfterPair?.pairingStatus).toBe("paired"); + expect(recordAfterPair?.publicKey).toBe(keyPair.publicKey.trim()); + expect(recordAfterPair?.secret).toBeTypeOf("string"); + + now += 2; + const nonce = "AUTHNONCESTRING000000001"; + const signingInput = createAuthRequestSigningInput({ + secret: recordAfterPair!.secret!, + nonce, + proofTimestamp: now + }); + const signature = await signMessage(keyPair.privateKey, signingInput); + await expect(verifySignature(keyPair.publicKey, signingInput, signature)).resolves.toBe(true); + + await runtime.handleMessage( + connection, + encodeBuiltin( + buildAuthRequest( + { + identifier: "client-a", + nonce, + proofTimestamp: now, + signature, + publicKey: keyPair.publicKey + }, + { requestId: "req-auth", timestamp: now } + ) + ) + ); + + const authSuccess = decodeBuiltin( + transportState.sentToConnection[transportState.sentToConnection.length - 1].message + ); + expect(authSuccess.type).toBe("auth_success"); + expect(transportState.promoted).toContain("client-a"); + + const session = runtime.state.registry.sessions.get("client-a"); + expect(session?.isAuthenticated).toBe(true); + + now += 5; + await runtime.handleMessage( + { ...connection, identifier: "client-a", isAuthenticated: true }, + encodeBuiltin( + buildHeartbeat( + { + identifier: "client-a", + status: "alive" + }, + { requestId: "req-heartbeat", timestamp: now } + ) + ) + ); + + const heartbeatAck = decodeBuiltin( + transportState.sentToConnection[transportState.sentToConnection.length - 1].message + ); + expect(heartbeatAck.type).toBe("heartbeat_ack"); + + const recordAfterHeartbeat = runtime.state.registry.clients.get("client-a"); + expect(recordAfterHeartbeat?.status).toBe("online"); + expect(recordAfterHeartbeat?.lastHeartbeatAt).toBe(now); + }); + + it("rejects unauthenticated rule messages by closing the connection", async () => { + const store = createMockStore(); + const transportState = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: transportState.transport, + now: () => 1_710_000_000 + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: connection.connectedAt, + lastActivityAt: 1_710_000_000 + }); + + await runtime.handleMessage(connection, 'chat_sync::{"body":"hello"}'); + + expect((connection.ws.close as ReturnType)).toHaveBeenCalledWith( + 1008, + "Not authenticated" + ); + }); + + it("marks stale authenticated clients unstable then offline during liveness sweep", async () => { + vi.useFakeTimers(); + try { + let now = 1_710_000_000; + const store = createMockStore(); + const transportState = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: transportState.transport, + now: () => now, + sweepIntervalMs: 1000 + }); + + await runtime.start(); + + runtime.state.registry.clients.set("client-a", { + identifier: "client-a", + pairingStatus: "paired", + publicKey: "pk", + secret: "secret", + status: "online", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now, + lastHeartbeatAt: now + }); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: createMockSocket(), + isAuthenticated: true, + connectedAt: now, + lastActivityAt: now + }); + + now += 7 * 60; + await vi.advanceTimersByTimeAsync(1000); + + expect(runtime.state.registry.clients.get("client-a")?.status).toBe("unstable"); + const unstableNotice = transportState.sentByIdentifier.at(-1); + expect(unstableNotice?.identifier).toBe("client-a"); + expect(decodeBuiltin(unstableNotice!.message).type).toBe("status_update"); + + now += 4 * 60; + await vi.advanceTimersByTimeAsync(1000); + + expect(runtime.state.registry.clients.get("client-a")?.status).toBe("offline"); + expect(transportState.closed).toContainEqual({ + identifier: "client-a", + code: 1001, + reason: "Heartbeat timeout" + }); + expect(runtime.state.registry.sessions.has("client-a")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); From 35972981d3f809740a662c9866ab6b18181d82ee Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 01:04:48 +0000 Subject: [PATCH 18/29] test: add auth failure path coverage --- tests/auth-failures.test.ts | 258 ++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 tests/auth-failures.test.ts diff --git a/tests/auth-failures.test.ts b/tests/auth-failures.test.ts new file mode 100644 index 0000000..bfe56fe --- /dev/null +++ b/tests/auth-failures.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { + buildAuthRequest, + decodeBuiltin, + encodeBuiltin, + createAuthRequestSigningInput, + YONEXUS_PROTOCOL_VERSION +} from "../../Yonexus.Protocol/src/index.js"; +import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; +import type { ClientRecord } from "../plugin/core/persistence.js"; +import type { YonexusServerStore } from "../plugin/core/store.js"; +import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js"; +import { generateKeyPair, signMessage } from "../../Yonexus.Client/plugin/crypto/keypair.js"; + +function createMockSocket() { + return { close: vi.fn() } as unknown as ClientConnection["ws"]; +} + +function createConnection(identifier: string | null = null): ClientConnection { + return { + identifier, + ws: createMockSocket(), + connectedAt: 1_710_000_000, + isAuthenticated: false + }; +} + +function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore { + const persisted = new Map(initialClients.map((record) => [record.identifier, record])); + + return { + filePath: "/tmp/yonexus-server-auth-failures.json", + load: vi.fn(async () => ({ + version: 1, + persistedAt: 1_710_000_000, + clients: new Map(persisted) + })), + save: vi.fn(async (clients: Iterable) => { + persisted.clear(); + for (const client of clients) { + persisted.set(client.identifier, client); + } + }) + }; +} + +function createMockTransport() { + const sent: Array<{ connection: ClientConnection; message: string }> = []; + + const transport: ServerTransport = { + isRunning: false, + connections: new Map(), + start: vi.fn(), + stop: vi.fn(), + send: vi.fn((identifier: string, message: string) => { + sent.push({ connection: { identifier } as ClientConnection, message }); + return true; + }), + sendToConnection: vi.fn((connection: ClientConnection, message: string) => { + sent.push({ connection, message }); + return true; + }), + broadcast: vi.fn(), + closeConnection: vi.fn(), + promoteToAuthenticated: vi.fn(), + removeTempConnection: vi.fn(), + assignIdentifierToTemp: vi.fn() + }; + + return { transport, sent }; +} + +describe("YNX-1105c: Auth Failure Paths", () => { + let now = 1_710_000_000; + + beforeEach(() => { + now = 1_710_000_000; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("AF-07: nonce collision triggers re_pair_required", async () => { + const keyPair = await generateKeyPair(); + const store = createMockStore([ + { + identifier: "client-a", + pairingStatus: "paired", + publicKey: keyPair.publicKey.trim(), + secret: "shared-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 10, + updatedAt: now - 10 + } + ]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: keyPair.publicKey.trim() + }); + + const nonce = "NONCE1234567890123456789"; + const signingInput = createAuthRequestSigningInput({ + secret: "shared-secret", + nonce, + proofTimestamp: now + }); + const signature = await signMessage(keyPair.privateKey, signingInput); + + await runtime.handleMessage( + connection, + encodeBuiltin( + buildAuthRequest( + { + identifier: "client-a", + nonce, + proofTimestamp: now, + signature, + publicKey: keyPair.publicKey.trim() + }, + { requestId: "req-auth-1", timestamp: now } + ) + ) + ); + + // Second request with same nonce triggers re-pair + now += 1; + const signingInput2 = createAuthRequestSigningInput({ + secret: "shared-secret", + nonce, + proofTimestamp: now + }); + const signature2 = await signMessage(keyPair.privateKey, signingInput2); + + await runtime.handleMessage( + connection, + encodeBuiltin( + buildAuthRequest( + { + identifier: "client-a", + nonce, + proofTimestamp: now, + signature: signature2, + publicKey: keyPair.publicKey.trim() + }, + { requestId: "req-auth-2", timestamp: now } + ) + ) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("re_pair_required"); + expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "nonce_collision" }); + + const record = runtime.state.registry.clients.get("client-a"); + expect(record?.secret).toBeUndefined(); + expect(record?.pairingStatus).toBe("revoked"); + }); + + it("AF-08: rate limit triggers re_pair_required", async () => { + const keyPair = await generateKeyPair(); + const store = createMockStore([ + { + identifier: "client-a", + pairingStatus: "paired", + publicKey: keyPair.publicKey.trim(), + secret: "shared-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: Array.from({ length: 10 }, () => now - 1), + createdAt: now - 10, + updatedAt: now - 10 + } + ]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: keyPair.publicKey.trim() + }); + + const nonce = "NONCE987654321098765432"; + const signingInput = createAuthRequestSigningInput({ + secret: "shared-secret", + nonce, + proofTimestamp: now + }); + const signature = await signMessage(keyPair.privateKey, signingInput); + + await runtime.handleMessage( + connection, + encodeBuiltin( + buildAuthRequest( + { + identifier: "client-a", + nonce, + proofTimestamp: now, + signature, + publicKey: keyPair.publicKey.trim() + }, + { requestId: "req-auth", timestamp: now } + ) + ) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("re_pair_required"); + expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "rate_limited" }); + + const record = runtime.state.registry.clients.get("client-a"); + expect(record?.secret).toBeUndefined(); + expect(record?.pairingStatus).toBe("revoked"); + }); +}); From 0717b204f129fefdade87cc2b70e4e1442091b9b Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 01:13:44 +0000 Subject: [PATCH 19/29] test: expand auth failure coverage --- tests/auth-failures.test.ts | 565 ++++++++++++++++++++++++++++++++---- 1 file changed, 506 insertions(+), 59 deletions(-) diff --git a/tests/auth-failures.test.ts b/tests/auth-failures.test.ts index bfe56fe..85d72f5 100644 --- a/tests/auth-failures.test.ts +++ b/tests/auth-failures.test.ts @@ -4,8 +4,7 @@ import { buildAuthRequest, decodeBuiltin, encodeBuiltin, - createAuthRequestSigningInput, - YONEXUS_PROTOCOL_VERSION + createAuthRequestSigningInput } from "../../Yonexus.Protocol/src/index.js"; import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; import type { ClientRecord } from "../plugin/core/persistence.js"; @@ -71,6 +70,42 @@ function createMockTransport() { return { transport, sent }; } +async function buildSignedAuthRequest(options: { + identifier: string; + secret: string; + privateKey: string; + publicKey: string; + nonce: string; + proofTimestamp: number; + requestId?: string; + signatureOverride?: string; + publicKeyOverride?: string; +}) { + const signature = + options.signatureOverride ?? + (await signMessage( + options.privateKey, + createAuthRequestSigningInput({ + secret: options.secret, + nonce: options.nonce, + proofTimestamp: options.proofTimestamp + }) + )); + + return encodeBuiltin( + buildAuthRequest( + { + identifier: options.identifier, + nonce: options.nonce, + proofTimestamp: options.proofTimestamp, + signature, + publicKey: options.publicKeyOverride ?? options.publicKey + }, + { requestId: options.requestId, timestamp: options.proofTimestamp } + ) + ); +} + describe("YNX-1105c: Auth Failure Paths", () => { let now = 1_710_000_000; @@ -83,6 +118,282 @@ describe("YNX-1105c: Auth Failure Paths", () => { vi.useRealTimers(); }); + it("AF-01: unknown identifier returns auth_failed(unknown_identifier)", async () => { + const keyPair = await generateKeyPair(); + const store = createMockStore([]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("rogue-client"); + await runtime.handleMessage( + connection, + await buildSignedAuthRequest({ + identifier: "rogue-client", + secret: "shared-secret", + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + nonce: "NONCE1234567890123456789", + proofTimestamp: now, + requestId: "req-auth-unknown" + }) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("auth_failed"); + expect(lastMessage.payload).toMatchObject({ + identifier: "rogue-client", + reason: "unknown_identifier" + }); + }); + + it("AF-02: auth before pairing returns auth_failed(not_paired)", async () => { + const keyPair = await generateKeyPair(); + const store = createMockStore([ + { + identifier: "client-a", + pairingStatus: "unpaired", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 10, + updatedAt: now - 10 + } + ]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: keyPair.publicKey.trim() + }); + + await runtime.handleMessage( + connection, + await buildSignedAuthRequest({ + identifier: "client-a", + secret: "shared-secret", + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + nonce: "NONCE1234567890123456789", + proofTimestamp: now, + requestId: "req-auth-not-paired" + }) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("auth_failed"); + expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "not_paired" }); + }); + + it("AF-03: invalid signature returns auth_failed(invalid_signature)", async () => { + const keyPair = await generateKeyPair(); + const wrongKeyPair = await generateKeyPair(); + const store = createMockStore([ + { + identifier: "client-a", + pairingStatus: "paired", + publicKey: keyPair.publicKey.trim(), + secret: "shared-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 10, + updatedAt: now - 10 + } + ]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: keyPair.publicKey.trim() + }); + + await runtime.handleMessage( + connection, + await buildSignedAuthRequest({ + identifier: "client-a", + secret: "shared-secret", + privateKey: wrongKeyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + nonce: "NONCE1234567890123456789", + proofTimestamp: now, + requestId: "req-auth-invalid-signature" + }) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("auth_failed"); + expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "invalid_signature" }); + }); + + it("AF-05: stale timestamp returns auth_failed(stale_timestamp)", async () => { + const keyPair = await generateKeyPair(); + const store = createMockStore([ + { + identifier: "client-a", + pairingStatus: "paired", + publicKey: keyPair.publicKey.trim(), + secret: "shared-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 20, + updatedAt: now - 20 + } + ]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: keyPair.publicKey.trim() + }); + + await runtime.handleMessage( + connection, + await buildSignedAuthRequest({ + identifier: "client-a", + secret: "shared-secret", + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + nonce: "NONCE1234567890123456789", + proofTimestamp: now - 11, + requestId: "req-auth-stale" + }) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("auth_failed"); + expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "stale_timestamp" }); + }); + + it("AF-06: future timestamp returns auth_failed(future_timestamp)", async () => { + const keyPair = await generateKeyPair(); + const store = createMockStore([ + { + identifier: "client-a", + pairingStatus: "paired", + publicKey: keyPair.publicKey.trim(), + secret: "shared-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 20, + updatedAt: now - 20 + } + ]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: keyPair.publicKey.trim() + }); + + await runtime.handleMessage( + connection, + await buildSignedAuthRequest({ + identifier: "client-a", + secret: "shared-secret", + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + nonce: "NONCE1234567890123456789", + proofTimestamp: now + 11, + requestId: "req-auth-future" + }) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("auth_failed"); + expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "future_timestamp" }); + }); + it("AF-07: nonce collision triggers re_pair_required", async () => { const keyPair = await generateKeyPair(); const store = createMockStore([ @@ -125,52 +436,33 @@ describe("YNX-1105c: Auth Failure Paths", () => { }); const nonce = "NONCE1234567890123456789"; - const signingInput = createAuthRequestSigningInput({ - secret: "shared-secret", - nonce, - proofTimestamp: now - }); - const signature = await signMessage(keyPair.privateKey, signingInput); await runtime.handleMessage( connection, - encodeBuiltin( - buildAuthRequest( - { - identifier: "client-a", - nonce, - proofTimestamp: now, - signature, - publicKey: keyPair.publicKey.trim() - }, - { requestId: "req-auth-1", timestamp: now } - ) - ) + await buildSignedAuthRequest({ + identifier: "client-a", + secret: "shared-secret", + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + nonce, + proofTimestamp: now, + requestId: "req-auth-1" + }) ); - // Second request with same nonce triggers re-pair now += 1; - const signingInput2 = createAuthRequestSigningInput({ - secret: "shared-secret", - nonce, - proofTimestamp: now - }); - const signature2 = await signMessage(keyPair.privateKey, signingInput2); await runtime.handleMessage( connection, - encodeBuiltin( - buildAuthRequest( - { - identifier: "client-a", - nonce, - proofTimestamp: now, - signature: signature2, - publicKey: keyPair.publicKey.trim() - }, - { requestId: "req-auth-2", timestamp: now } - ) - ) + await buildSignedAuthRequest({ + identifier: "client-a", + secret: "shared-secret", + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + nonce, + proofTimestamp: now, + requestId: "req-auth-2" + }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); @@ -223,28 +515,17 @@ describe("YNX-1105c: Auth Failure Paths", () => { publicKey: keyPair.publicKey.trim() }); - const nonce = "NONCE987654321098765432"; - const signingInput = createAuthRequestSigningInput({ - secret: "shared-secret", - nonce, - proofTimestamp: now - }); - const signature = await signMessage(keyPair.privateKey, signingInput); - await runtime.handleMessage( connection, - encodeBuiltin( - buildAuthRequest( - { - identifier: "client-a", - nonce, - proofTimestamp: now, - signature, - publicKey: keyPair.publicKey.trim() - }, - { requestId: "req-auth", timestamp: now } - ) - ) + await buildSignedAuthRequest({ + identifier: "client-a", + secret: "shared-secret", + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + nonce: "NONCE9876543210987654321", + proofTimestamp: now, + requestId: "req-auth-rate-limit" + }) ); const lastMessage = decodeBuiltin(sent.at(-1)!.message); @@ -255,4 +536,170 @@ describe("YNX-1105c: Auth Failure Paths", () => { expect(record?.secret).toBeUndefined(); expect(record?.pairingStatus).toBe("revoked"); }); + + it("AF-09: wrong public key returns auth_failed(invalid_signature)", async () => { + const keyPair = await generateKeyPair(); + const rotatedKeyPair = await generateKeyPair(); + const store = createMockStore([ + { + identifier: "client-a", + pairingStatus: "paired", + publicKey: keyPair.publicKey.trim(), + secret: "shared-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 10, + updatedAt: now - 10 + } + ]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: keyPair.publicKey.trim() + }); + + await runtime.handleMessage( + connection, + await buildSignedAuthRequest({ + identifier: "client-a", + secret: "shared-secret", + privateKey: rotatedKeyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + publicKeyOverride: rotatedKeyPair.publicKey.trim(), + nonce: "NONCE1234567890123456789", + proofTimestamp: now, + requestId: "req-auth-wrong-public-key" + }) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("auth_failed"); + expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "invalid_signature" }); + }); + + it("AF-10: malformed auth_request payload returns protocol error", async () => { + const store = createMockStore([]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + await runtime.handleMessage( + connection, + encodeBuiltin({ + type: "auth_request", + requestId: "req-auth-malformed", + timestamp: now + }) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("error"); + expect(lastMessage.payload).toMatchObject({ + code: "MALFORMED_MESSAGE", + message: "auth_request payload is required" + }); + }); + + it("AF-11: tampered signature returns auth_failed(invalid_signature)", async () => { + const keyPair = await generateKeyPair(); + const store = createMockStore([ + { + identifier: "client-a", + pairingStatus: "paired", + publicKey: keyPair.publicKey.trim(), + secret: "shared-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 10, + updatedAt: now - 10 + } + ]); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: keyPair.publicKey.trim() + }); + + const validMessage = decodeBuiltin( + await buildSignedAuthRequest({ + identifier: "client-a", + secret: "shared-secret", + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey.trim(), + nonce: "NONCE1234567890123456789", + proofTimestamp: now, + requestId: "req-auth-tampered" + }) + ); + + await runtime.handleMessage( + connection, + encodeBuiltin({ + ...validMessage, + payload: { + ...validMessage.payload, + signature: `A${String(validMessage.payload?.signature).slice(1)}` + } + }) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("auth_failed"); + expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "invalid_signature" }); + }); }); From 3c760fc0f42d2a22634f0f84aa5cf493a546185f Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 01:19:13 +0000 Subject: [PATCH 20/29] test: cover unauth rule + heartbeat failures --- tests/connection-heartbeat-failures.test.ts | 196 ++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/connection-heartbeat-failures.test.ts diff --git a/tests/connection-heartbeat-failures.test.ts b/tests/connection-heartbeat-failures.test.ts new file mode 100644 index 0000000..659e16f --- /dev/null +++ b/tests/connection-heartbeat-failures.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { buildHeartbeat, decodeBuiltin, encodeBuiltin } from "../../Yonexus.Protocol/src/index.js"; +import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; +import { createClientRecord, type ClientRecord } from "../plugin/core/persistence.js"; +import type { YonexusServerStore } from "../plugin/core/store.js"; +import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js"; + +function createMockSocket() { + return { close: vi.fn() } as unknown as ClientConnection["ws"]; +} + +function createConnection(identifier: string | null = null): ClientConnection { + return { + identifier, + ws: createMockSocket(), + connectedAt: 1_710_000_000, + isAuthenticated: false + }; +} + +function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore { + const persisted = new Map(initialClients.map((record) => [record.identifier, record])); + + return { + filePath: "/tmp/yonexus-server-connection-heartbeat-failures.json", + load: vi.fn(async () => ({ + version: 1, + persistedAt: 1_710_000_000, + clients: new Map(persisted) + })), + save: vi.fn(async (clients: Iterable) => { + persisted.clear(); + for (const client of clients) { + persisted.set(client.identifier, client); + } + }) + }; +} + +function createMockTransport() { + const sent: Array<{ connection: ClientConnection; message: string }> = []; + + const transport: ServerTransport = { + isRunning: false, + connections: new Map(), + start: vi.fn(), + stop: vi.fn(), + send: vi.fn(), + sendToConnection: vi.fn((connection: ClientConnection, message: string) => { + sent.push({ connection, message }); + return true; + }), + broadcast: vi.fn(), + closeConnection: vi.fn(), + promoteToAuthenticated: vi.fn(), + removeTempConnection: vi.fn(), + assignIdentifierToTemp: vi.fn() + }; + + return { transport, sent }; +} + +describe("YNX-1105d: Connection & Heartbeat Failure Paths", () => { + let now = 1_710_000_000; + + beforeEach(() => { + now = 1_710_000_000; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("CF-06: unauthenticated rule message closes connection", async () => { + const record = createClientRecord("client-a"); + const store = createMockStore([record]); + const { transport } = createMockTransport(); + + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: undefined + }); + + await runtime.handleMessage(connection, "chat::hello"); + + expect(connection.ws.close).toHaveBeenCalledWith(1008, "Not authenticated"); + }); + + it("HF-03: heartbeat before auth returns error", async () => { + const record = createClientRecord("client-a"); + const store = createMockStore([record]); + const { transport, sent } = createMockTransport(); + + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + runtime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: connection.ws, + isAuthenticated: false, + connectedAt: now, + lastActivityAt: now, + publicKey: undefined + }); + + await runtime.handleMessage( + connection, + encodeBuiltin( + buildHeartbeat( + { identifier: "client-a", status: "alive" }, + { requestId: "req-hb-early", timestamp: now } + ) + ) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("error"); + expect(lastMessage.payload).toMatchObject({ + code: "AUTH_FAILED" + }); + }); + + it("HF-04: heartbeat without session returns error", async () => { + const record = createClientRecord("client-a"); + const store = createMockStore([record]); + const { transport, sent } = createMockTransport(); + + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection("client-a"); + + await runtime.handleMessage( + connection, + encodeBuiltin( + buildHeartbeat( + { identifier: "client-a", status: "alive" }, + { requestId: "req-hb-unauth", timestamp: now } + ) + ) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("error"); + expect(lastMessage.payload).toMatchObject({ + code: "AUTH_FAILED" + }); + }); +}); From 5bda184a8fc50fb5db3c07cc77d6106205315e20 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 01:32:49 +0000 Subject: [PATCH 21/29] test: cover server recovery scenarios --- tests/state-recovery.test.ts | 203 +++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/state-recovery.test.ts diff --git a/tests/state-recovery.test.ts b/tests/state-recovery.test.ts new file mode 100644 index 0000000..79b60a9 --- /dev/null +++ b/tests/state-recovery.test.ts @@ -0,0 +1,203 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; +import { + createYonexusServerStore, + loadServerStore, + YonexusServerStoreCorruptionError +} from "../plugin/core/store.js"; +import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js"; +import { + buildHello, + decodeBuiltin, + encodeBuiltin, + type BuiltinEnvelope, + type PairRequestPayload, + YONEXUS_PROTOCOL_VERSION +} from "../../Yonexus.Protocol/src/index.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function createTempServerStorePath(): Promise { + const dir = await mkdtemp(join(tmpdir(), "yonexus-server-recovery-")); + tempDirs.push(dir); + return join(dir, "server-store.json"); +} + +function createMockSocket() { + return { + close: vi.fn() + } as unknown as ClientConnection["ws"]; +} + +function createConnection(identifier: string | null = null): ClientConnection { + return { + identifier, + ws: createMockSocket(), + connectedAt: 1_710_000_000, + isAuthenticated: false + }; +} + +function createMockTransport() { + const sentToConnection: Array<{ connection: ClientConnection; message: string }> = []; + + const transport: ServerTransport = { + isRunning: false, + connections: new Map(), + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + send: vi.fn(() => true), + sendToConnection: vi.fn((connection: ClientConnection, message: string) => { + sentToConnection.push({ connection, message }); + return true; + }), + broadcast: vi.fn(), + closeConnection: vi.fn(() => true), + promoteToAuthenticated: vi.fn(() => true), + removeTempConnection: vi.fn(), + assignIdentifierToTemp: vi.fn() + }; + + return { + transport, + sentToConnection + }; +} + +describe("YNX-1105e: Server state recovery", () => { + it("SR-01: preserves pending pairing across restart and reuses the same pairing code", async () => { + const storePath = await createTempServerStorePath(); + const store = createYonexusServerStore(storePath); + let now = 1_710_000_000; + + const firstTransport = createMockTransport(); + const firstRuntime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: firstTransport.transport, + now: () => now + }); + + await firstRuntime.start(); + + const firstConnection = createConnection(); + await firstRuntime.handleMessage( + firstConnection, + encodeBuiltin( + buildHello( + { + identifier: "client-a", + hasSecret: false, + hasKeyPair: false, + protocolVersion: YONEXUS_PROTOCOL_VERSION + }, + { requestId: "req-hello-1", timestamp: now } + ) + ) + ); + + const initialRecord = firstRuntime.state.registry.clients.get("client-a"); + const initialPairingCode = initialRecord?.pairingCode; + const initialExpiresAt = initialRecord?.pairingExpiresAt; + + expect(initialRecord?.pairingStatus).toBe("pending"); + expect(initialPairingCode).toBeTypeOf("string"); + expect(initialExpiresAt).toBeTypeOf("number"); + + await firstRuntime.stop(); + + const persistedRaw = JSON.parse(await readFile(storePath, "utf8")) as { + clients: Array<{ identifier: string; pairingStatus: string; pairingCode?: string }>; + }; + expect( + persistedRaw.clients.find((client) => client.identifier === "client-a") + ).toMatchObject({ + identifier: "client-a", + pairingStatus: "pending", + pairingCode: initialPairingCode + }); + + now += 30; + + const secondTransport = createMockTransport(); + const secondRuntime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: secondTransport.transport, + now: () => now + }); + + await secondRuntime.start(); + + const reloadedRecord = secondRuntime.state.registry.clients.get("client-a"); + expect(reloadedRecord?.pairingStatus).toBe("pending"); + expect(reloadedRecord?.pairingCode).toBe(initialPairingCode); + expect(reloadedRecord?.pairingExpiresAt).toBe(initialExpiresAt); + + const secondConnection = createConnection(); + await secondRuntime.handleMessage( + secondConnection, + encodeBuiltin( + buildHello( + { + identifier: "client-a", + hasSecret: false, + hasKeyPair: false, + protocolVersion: YONEXUS_PROTOCOL_VERSION + }, + { requestId: "req-hello-2", timestamp: now } + ) + ) + ); + + const helloAck = decodeBuiltin(secondTransport.sentToConnection[0].message); + expect(helloAck.type).toBe("hello_ack"); + expect(helloAck.payload).toMatchObject({ + identifier: "client-a", + nextAction: "waiting_pair_confirm" + }); + + const pairRequest = decodeBuiltin(secondTransport.sentToConnection[1].message) as BuiltinEnvelope< + "pair_request", + PairRequestPayload + >; + expect(pairRequest.type).toBe("pair_request"); + expect(pairRequest.payload).toMatchObject({ + identifier: "client-a", + adminNotification: "sent", + codeDelivery: "out_of_band" + }); + expect(pairRequest.payload?.expiresAt).toBe(initialExpiresAt); + + await secondRuntime.stop(); + }); + + it("SR-05: corrupted server store raises YonexusServerStoreCorruptionError", async () => { + const storePath = await createTempServerStorePath(); + await writeFile(storePath, '{"version":1,"clients":"oops"}\n', "utf8"); + + await expect(loadServerStore(storePath)).rejects.toBeInstanceOf(YonexusServerStoreCorruptionError); + await expect(loadServerStore(storePath)).rejects.toThrow("invalid clients array"); + }); +}); From 9bd62e5ee98e9248340a4423ab0a6b319b5bd6a7 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 02:04:06 +0000 Subject: [PATCH 22/29] test: cover connection failure edge cases --- tests/connection-heartbeat-failures.test.ts | 111 +++++++++++++++++++- 1 file changed, 106 insertions(+), 5 deletions(-) diff --git a/tests/connection-heartbeat-failures.test.ts b/tests/connection-heartbeat-failures.test.ts index 659e16f..e9e43f3 100644 --- a/tests/connection-heartbeat-failures.test.ts +++ b/tests/connection-heartbeat-failures.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { buildHeartbeat, decodeBuiltin, encodeBuiltin } from "../../Yonexus.Protocol/src/index.js"; +import { + buildHeartbeat, + buildHello, + decodeBuiltin, + encodeBuiltin, + YONEXUS_PROTOCOL_VERSION +} from "../../Yonexus.Protocol/src/index.js"; import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; import { createClientRecord, type ClientRecord } from "../plugin/core/persistence.js"; import type { YonexusServerStore } from "../plugin/core/store.js"; @@ -40,10 +46,12 @@ function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStor function createMockTransport() { const sent: Array<{ connection: ClientConnection; message: string }> = []; + const tempAssignments = new Map(); + const connections = new Map(); const transport: ServerTransport = { isRunning: false, - connections: new Map(), + connections, start: vi.fn(), stop: vi.fn(), send: vi.fn(), @@ -53,9 +61,31 @@ function createMockTransport() { }), broadcast: vi.fn(), closeConnection: vi.fn(), - promoteToAuthenticated: vi.fn(), - removeTempConnection: vi.fn(), - assignIdentifierToTemp: vi.fn() + promoteToAuthenticated: vi.fn((identifier: string, ws: ClientConnection["ws"]) => { + if (!tempAssignments.has(ws)) { + return false; + } + + const existing = connections.get(identifier); + if (existing) { + existing.ws.close(1008, "Connection replaced by new authenticated session"); + } + + connections.set(identifier, { + identifier, + ws, + connectedAt: 1_710_000_000, + isAuthenticated: true + }); + tempAssignments.delete(ws); + return true; + }), + removeTempConnection: vi.fn((ws: ClientConnection["ws"]) => { + tempAssignments.delete(ws); + }), + assignIdentifierToTemp: vi.fn((ws: ClientConnection["ws"], identifier: string) => { + tempAssignments.set(ws, identifier); + }) }; return { transport, sent }; @@ -193,4 +223,75 @@ describe("YNX-1105d: Connection & Heartbeat Failure Paths", () => { code: "AUTH_FAILED" }); }); + + it("CF-04: protocol version mismatch returns error and closes the connection", async () => { + const record = createClientRecord("client-a"); + const store = createMockStore([record]); + const { transport, sent } = createMockTransport(); + + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const connection = createConnection(); + await runtime.handleMessage( + connection, + encodeBuiltin( + buildHello( + { + identifier: "client-a", + hasSecret: false, + hasKeyPair: false, + protocolVersion: `${YONEXUS_PROTOCOL_VERSION}-unsupported` + }, + { requestId: "req-hello-version", timestamp: now } + ) + ) + ); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("error"); + expect(lastMessage.payload).toMatchObject({ + code: "UNSUPPORTED_PROTOCOL_VERSION" + }); + expect(connection.ws.close).toHaveBeenCalledWith(1002, "Unsupported protocol version"); + }); + + it("CF-03: promoting a new authenticated connection replaces the old one", async () => { + const previousSocket = createMockSocket(); + const replacementSocket = createMockSocket(); + const previousConnection = { + identifier: "client-a", + ws: previousSocket, + connectedAt: now - 5, + isAuthenticated: true + } satisfies ClientConnection; + + const { transport } = createMockTransport(); + transport.connections.set("client-a", previousConnection); + + transport.assignIdentifierToTemp(replacementSocket, "client-a"); + const promoted = transport.promoteToAuthenticated("client-a", replacementSocket); + + expect(promoted).toBe(true); + expect(previousSocket.close).toHaveBeenCalledWith( + 1008, + "Connection replaced by new authenticated session" + ); + + const activeConnection = transport.connections.get("client-a"); + expect(activeConnection?.ws).toBe(replacementSocket); + expect(activeConnection?.isAuthenticated).toBe(true); + }); }); From e038fb766680a766446420f44798c04e2b10709e Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 03:02:31 +0000 Subject: [PATCH 23/29] test: cover malformed hello runtime flow --- tests/runtime-flow.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts index 76d13a3..82a0f75 100644 --- a/tests/runtime-flow.test.ts +++ b/tests/runtime-flow.test.ts @@ -287,6 +287,44 @@ describe("Yonexus.Server runtime flow", () => { expect(recordAfterHeartbeat?.lastHeartbeatAt).toBe(now); }); + it("returns MALFORMED_MESSAGE for hello without payload and keeps the connection open", async () => { + const store = createMockStore(); + const transportState = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: transportState.transport, + now: () => 1_710_000_000 + }); + + await runtime.start(); + + const connection = createConnection(); + await runtime.handleMessage( + connection, + encodeBuiltin({ + type: "hello", + requestId: "req-bad-hello", + timestamp: 1_710_000_000 + }) + ); + + expect(transportState.sentToConnection).toHaveLength(1); + const errorResponse = decodeBuiltin(transportState.sentToConnection[0].message); + expect(errorResponse.type).toBe("error"); + expect(errorResponse.payload).toMatchObject({ + code: "MALFORMED_MESSAGE", + message: "hello payload is required" + }); + expect((connection.ws.close as ReturnType)).not.toHaveBeenCalled(); + }); + it("rejects unauthenticated rule messages by closing the connection", async () => { const store = createMockStore(); const transportState = createMockTransport(); From b67166fd121218f082f8e630d1ed30972cdb9ab6 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 03:33:09 +0000 Subject: [PATCH 24/29] test: cover server restart session recovery --- tests/state-recovery.test.ts | 97 ++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/state-recovery.test.ts b/tests/state-recovery.test.ts index 79b60a9..5bcdbdd 100644 --- a/tests/state-recovery.test.ts +++ b/tests/state-recovery.test.ts @@ -193,6 +193,103 @@ describe("YNX-1105e: Server state recovery", () => { await secondRuntime.stop(); }); + it("SR-02: restart drops in-memory active sessions and requires reconnect", async () => { + const storePath = await createTempServerStorePath(); + const store = createYonexusServerStore(storePath); + const now = 1_710_000_000; + + const firstTransport = createMockTransport(); + const firstRuntime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: firstTransport.transport, + now: () => now + }); + + await firstRuntime.start(); + const record = firstRuntime.state.registry.clients.get("client-a"); + expect(record).toBeDefined(); + + record!.pairingStatus = "paired"; + record!.publicKey = "test-public-key"; + record!.secret = "test-secret"; + record!.status = "online"; + record!.lastAuthenticatedAt = now; + record!.lastHeartbeatAt = now; + record!.updatedAt = now; + + firstRuntime.state.registry.sessions.set("client-a", { + identifier: "client-a", + socket: createMockSocket(), + isAuthenticated: true, + connectedAt: now, + lastActivityAt: now, + publicKey: "test-public-key" + }); + + await firstRuntime.stop(); + + const secondTransport = createMockTransport(); + const secondRuntime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "stub-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport: secondTransport.transport, + now: () => now + 5 + }); + + await secondRuntime.start(); + + const reloadedRecord = secondRuntime.state.registry.clients.get("client-a"); + expect(reloadedRecord).toMatchObject({ + identifier: "client-a", + pairingStatus: "paired", + secret: "test-secret", + publicKey: "test-public-key", + status: "online", + lastAuthenticatedAt: now, + lastHeartbeatAt: now + }); + expect(secondRuntime.state.registry.sessions.size).toBe(0); + + const reconnectConnection = createConnection(); + await secondRuntime.handleMessage( + reconnectConnection, + encodeBuiltin( + buildHello( + { + identifier: "client-a", + hasSecret: true, + hasKeyPair: true, + publicKey: "test-public-key", + protocolVersion: YONEXUS_PROTOCOL_VERSION + }, + { requestId: "req-hello-reconnect", timestamp: now + 5 } + ) + ) + ); + + const helloAck = decodeBuiltin(secondTransport.sentToConnection[0].message); + expect(helloAck.type).toBe("hello_ack"); + expect(helloAck.payload).toMatchObject({ + identifier: "client-a", + nextAction: "auth_required" + }); + + await secondRuntime.stop(); + }); + it("SR-05: corrupted server store raises YonexusServerStoreCorruptionError", async () => { const storePath = await createTempServerStorePath(); await writeFile(storePath, '{"version":1,"clients":"oops"}\n', "utf8"); From 2972c4750e34cd453ec3fdbed6bf251603e44187 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 04:06:06 +0000 Subject: [PATCH 25/29] feat(server): wire Discord DM pairing notifications --- README.md | 13 ++- plugin/notifications/discord.ts | 160 +++++++++++++++++++++++++------- tests/notifications.test.ts | 107 +++++++++++++++++++++ tests/runtime-flow.test.ts | 20 ++++ 4 files changed, 257 insertions(+), 43 deletions(-) create mode 100644 tests/notifications.test.ts diff --git a/README.md b/README.md index 9be3f84..d723e9d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ It runs on the main OpenClaw instance and is responsible for: ## Status -Current state: **scaffold + core runtime MVP** +Current state: **core runtime MVP with Discord DM transport wired via REST API** Implemented in this repository today: @@ -25,14 +25,13 @@ Implemented in this repository today: - auth proof validation flow - heartbeat receive + liveness sweep - rule registry + send-to-client APIs -- notification service stub/mock for pairing DM flow +- Discord DM pairing notifications via Discord REST API (`notifyBotToken` + `adminUserId`) Still pending before production use: -- automated Server unit/integration tests -- real Discord DM transport wiring -- operator hardening / troubleshooting docs - broader lifecycle integration with real OpenClaw plugin hooks +- more operator-facing hardening / troubleshooting polish +- expanded edge-case and live-environment validation beyond the current automated suite ## Install Layout @@ -133,11 +132,11 @@ npm run check Current known limitations: -- pairing DM sending is still a stub/mock abstraction +- DM delivery depends on Discord bot permissions and the target user's DM settings - no offline message queueing - no multi-server topology - no management UI -- no server-side unit/integration test suite yet +- transport is covered mainly by automated tests rather than live Discord end-to-end validation ## Related Repos diff --git a/plugin/notifications/discord.ts b/plugin/notifications/discord.ts index aad958c..c629965 100644 --- a/plugin/notifications/discord.ts +++ b/plugin/notifications/discord.ts @@ -1,11 +1,11 @@ /** * Yonexus Server - Discord Notification Service - * + * * Sends pairing notifications to the configured admin user via Discord DM. */ import type { PairingRequest } from "../services/pairing.js"; -import { redactPairingCode } from "../core/logging.js"; +import { redactPairingCode, safeErrorMessage } from "../core/logging.js"; export interface DiscordNotificationService { /** @@ -20,55 +20,143 @@ export interface DiscordNotificationConfig { adminUserId: string; } +export interface DiscordApiResponse { + ok: boolean; + status: number; + json(): Promise; +} + +export type DiscordFetch = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + } +) => Promise; + +interface CreateDmChannelResponse { + id?: string; +} + +interface SendDiscordDirectMessageOptions { + config: DiscordNotificationConfig; + message: string; + fetcher?: DiscordFetch; +} + +const DISCORD_API_BASE_URL = "https://discord.com/api/v10"; + /** - * Create a Discord notification service. - * - * Note: This is a framework stub. Full implementation requires: - * - Discord bot client integration (e.g., using discord.js) - * - DM channel creation/fetching - * - Error handling for blocked DMs, invalid tokens, etc. - * - * For v1, this provides the interface and a mock implementation - * that logs to console. Production deployments should replace - * this with actual Discord bot integration. + * Create a Discord notification service backed by Discord's REST API. + * + * Flow: + * 1. Create or fetch a DM channel for the configured admin user + * 2. Post the formatted pairing message into that DM channel */ export function createDiscordNotificationService( - config: DiscordNotificationConfig + config: DiscordNotificationConfig, + options: { fetcher?: DiscordFetch } = {} ): DiscordNotificationService { + const fetcher = options.fetcher ?? getDefaultFetch(); + return { async sendPairingNotification(request: PairingRequest): Promise { - const redactedCode = redactPairingCode(request.pairingCode); - - // Log to console (visible in OpenClaw logs) - console.log("[Yonexus.Server] Pairing notification (Discord DM stub):", { - identifier: request.identifier, - pairingCode: redactedCode, - expiresAt: request.expiresAt, - ttlSeconds: request.ttlSeconds, - adminUserId: config.adminUserId - }); - - // TODO: Replace with actual Discord bot integration - // Example with discord.js: - // const client = new Client({ intents: [GatewayIntentBits.DirectMessages] }); - // await client.login(config.botToken); - // const user = await client.users.fetch(config.adminUserId); - // await user.send(message); - - // For now, return true to allow pairing flow to continue - // In production, this should return false if DM fails - return true; + if (!config.botToken.trim() || !config.adminUserId.trim()) { + console.error("[Yonexus.Server] Discord DM notification misconfigured", { + hasBotToken: Boolean(config.botToken.trim()), + hasAdminUserId: Boolean(config.adminUserId.trim()) + }); + return false; + } + + try { + await sendDiscordDirectMessage({ + config, + message: formatPairingMessage(request), + fetcher + }); + + console.log("[Yonexus.Server] Pairing notification sent via Discord DM", { + identifier: request.identifier, + pairingCode: redactPairingCode(request.pairingCode), + expiresAt: request.expiresAt, + ttlSeconds: request.ttlSeconds, + adminUserId: config.adminUserId + }); + + return true; + } catch (error) { + console.error("[Yonexus.Server] Failed to send Discord DM pairing notification", { + identifier: request.identifier, + pairingCode: redactPairingCode(request.pairingCode), + adminUserId: config.adminUserId, + error: safeErrorMessage(error) + }); + return false; + } } }; } +async function sendDiscordDirectMessage( + options: SendDiscordDirectMessageOptions +): Promise { + const { config, message, fetcher } = options; + const headers = { + Authorization: `Bot ${config.botToken}`, + "Content-Type": "application/json" + }; + + const dmResponse = await fetcher(`${DISCORD_API_BASE_URL}/users/@me/channels`, { + method: "POST", + headers, + body: JSON.stringify({ recipient_id: config.adminUserId }) + }); + + if (!dmResponse.ok) { + throw new Error(`Discord DM channel creation failed with status ${dmResponse.status}`); + } + + const dmPayload = (await dmResponse.json()) as CreateDmChannelResponse; + const channelId = dmPayload.id?.trim(); + if (!channelId) { + throw new Error("Discord DM channel creation did not return a channel id"); + } + + const messageResponse = await fetcher(`${DISCORD_API_BASE_URL}/channels/${channelId}/messages`, { + method: "POST", + headers, + body: JSON.stringify({ content: message }) + }); + + if (!messageResponse.ok) { + throw new Error(`Discord DM send failed with status ${messageResponse.status}`); + } + + await messageResponse.json(); +} + +function getDefaultFetch(): DiscordFetch { + if (typeof fetch !== "function") { + throw new Error("Global fetch is not available in this runtime"); + } + + return (input, init) => + fetch(input, { + method: init?.method, + headers: init?.headers, + body: init?.body + }) as Promise; +} + /** * Format a pairing request as a Discord DM message. */ export function formatPairingMessage(request: PairingRequest): string { const expiresDate = new Date(request.expiresAt * 1000); const expiresStr = expiresDate.toISOString(); - + return [ "🔐 **Yonexus Pairing Request**", "", @@ -90,7 +178,7 @@ export function createMockNotificationService( options: { shouldSucceed?: boolean } = {} ): DiscordNotificationService { const shouldSucceed = options.shouldSucceed ?? true; - + return { async sendPairingNotification(request: PairingRequest): Promise { console.log("[Yonexus.Server] Mock pairing notification:"); diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts new file mode 100644 index 0000000..a920102 --- /dev/null +++ b/tests/notifications.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createDiscordNotificationService, + formatPairingMessage, + type DiscordFetch +} from "../plugin/notifications/discord.js"; + +const request = { + identifier: "client-a", + pairingCode: "PAIR-1234-CODE", + expiresAt: 1_710_000_300, + ttlSeconds: 300, + createdAt: 1_710_000_000 +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Discord notification service", () => { + it("formats pairing requests as a DM-friendly message", () => { + const message = formatPairingMessage(request); + + expect(message).toContain("Yonexus Pairing Request"); + expect(message).toContain("`client-a`"); + expect(message).toContain("`PAIR-1234-CODE`"); + expect(message).toContain("TTL:** 300 seconds"); + }); + + it("creates a DM channel and posts the pairing message", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: "dm-channel-1" }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: "message-1" }) + }); + + const service = createDiscordNotificationService( + { + botToken: "discord-bot-token", + adminUserId: "123456789012345678" + }, + { fetcher } + ); + + await expect(service.sendPairingNotification(request)).resolves.toBe(true); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher).toHaveBeenNthCalledWith( + 1, + "https://discord.com/api/v10/users/@me/channels", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bot discord-bot-token" + }), + body: JSON.stringify({ recipient_id: "123456789012345678" }) + }) + ); + expect(fetcher).toHaveBeenNthCalledWith( + 2, + "https://discord.com/api/v10/channels/dm-channel-1/messages", + expect.objectContaining({ + method: "POST" + }) + ); + }); + + it("returns false when Discord rejects DM channel creation", async () => { + const fetcher = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ message: "Missing Access" }) + }); + + const service = createDiscordNotificationService( + { + botToken: "discord-bot-token", + adminUserId: "123456789012345678" + }, + { fetcher } + ); + + await expect(service.sendPairingNotification(request)).resolves.toBe(false); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("returns false when config is missing required Discord credentials", async () => { + const fetcher = vi.fn(); + const service = createDiscordNotificationService( + { + botToken: "", + adminUserId: "" + }, + { fetcher } + ); + + await expect(service.sendPairingNotification(request)).resolves.toBe(false); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts index 82a0f75..778177b 100644 --- a/tests/runtime-flow.test.ts +++ b/tests/runtime-flow.test.ts @@ -98,8 +98,27 @@ function createMockTransport() { }; } +function stubDiscordFetchSuccess() { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: "dm-channel-1" }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: "message-1" }) + }) + ); +} + describe("Yonexus.Server runtime flow", () => { it("runs hello -> pair_request for an unpaired client", async () => { + stubDiscordFetchSuccess(); const store = createMockStore(); const transportState = createMockTransport(); const runtime = createYonexusServerRuntime({ @@ -160,6 +179,7 @@ describe("Yonexus.Server runtime flow", () => { }); it("completes pair_confirm -> auth_request -> heartbeat for a client", async () => { + stubDiscordFetchSuccess(); let now = 1_710_000_000; const keyPair = await generateKeyPair(); const store = createMockStore(); From 31f41cb49bb2d4ac22059b296a996115a1d822dd Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 04:38:07 +0000 Subject: [PATCH 26/29] Fix strict TypeScript checks for server --- plugin/core/config.ts | 18 +++++++++++------- plugin/core/persistence.ts | 5 +++++ plugin/core/runtime.ts | 11 +++++++---- plugin/notifications/discord.ts | 2 +- plugin/types/ws.d.ts | 21 +++++++++++++++++++++ tests/state-recovery.test.ts | 3 +++ tsconfig.json | 6 ++++-- 7 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 plugin/types/ws.d.ts diff --git a/plugin/core/config.ts b/plugin/core/config.ts index 0bb78cc..eee4bc8 100644 --- a/plugin/core/config.ts +++ b/plugin/core/config.ts @@ -35,7 +35,7 @@ function normalizeOptionalString(value: unknown): string | undefined { } function isValidPort(value: unknown): value is number { - return Number.isInteger(value) && value >= 1 && value <= 65535; + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; } function isValidWsUrl(value: string): boolean { @@ -67,18 +67,18 @@ export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig { issues.push("followerIdentifiers must not contain duplicates"); } - const notifyBotToken = source.notifyBotToken; - if (!isNonEmptyString(notifyBotToken)) { + const rawNotifyBotToken = source.notifyBotToken; + if (!isNonEmptyString(rawNotifyBotToken)) { issues.push("notifyBotToken is required"); } - const adminUserId = source.adminUserId; - if (!isNonEmptyString(adminUserId)) { + const rawAdminUserId = source.adminUserId; + if (!isNonEmptyString(rawAdminUserId)) { issues.push("adminUserId is required"); } - const listenPort = source.listenPort; - if (!isValidPort(listenPort)) { + const rawListenPort = source.listenPort; + if (!isValidPort(rawListenPort)) { issues.push("listenPort must be an integer between 1 and 65535"); } @@ -93,6 +93,10 @@ export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig { throw new YonexusServerConfigError(issues); } + const notifyBotToken = rawNotifyBotToken as string; + const adminUserId = rawAdminUserId as string; + const listenPort = rawListenPort as number; + return { followerIdentifiers, notifyBotToken: notifyBotToken.trim(), diff --git a/plugin/core/persistence.ts b/plugin/core/persistence.ts index da93fcc..80705cd 100644 --- a/plugin/core/persistence.ts +++ b/plugin/core/persistence.ts @@ -79,6 +79,9 @@ export interface ClientRecord { /** 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. @@ -151,6 +154,7 @@ export interface SerializedClientRecord { status: ClientLivenessStatus; lastHeartbeatAt?: number; lastAuthenticatedAt?: number; + pairedAt?: number; createdAt: number; updatedAt: number; // Note: recentNonces and recentHandshakeAttempts are intentionally @@ -203,6 +207,7 @@ export function serializeClientRecord(record: ClientRecord): SerializedClientRec status: record.status, lastHeartbeatAt: record.lastHeartbeatAt, lastAuthenticatedAt: record.lastAuthenticatedAt, + pairedAt: record.pairedAt, createdAt: record.createdAt, updatedAt: record.updatedAt }; diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 207b7b8..68bf972 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -52,6 +52,7 @@ export interface YonexusServerRuntimeOptions { config: YonexusServerConfig; store: YonexusServerStore; transport: ServerTransport; + notificationService?: DiscordNotificationService; now?: () => number; sweepIntervalMs?: number; } @@ -80,10 +81,12 @@ export class YonexusServerRuntime { }; this.sweepIntervalMs = options.sweepIntervalMs ?? 30_000; this.pairingService = createPairingService({ now: this.now }); - this.notificationService = createDiscordNotificationService({ - botToken: options.config.notifyBotToken, - adminUserId: options.config.adminUserId - }); + this.notificationService = + options.notificationService ?? + createDiscordNotificationService({ + botToken: options.config.notifyBotToken, + adminUserId: options.config.adminUserId + }); } get state(): ServerLifecycleState { diff --git a/plugin/notifications/discord.ts b/plugin/notifications/discord.ts index c629965..b10a342 100644 --- a/plugin/notifications/discord.ts +++ b/plugin/notifications/discord.ts @@ -42,7 +42,7 @@ interface CreateDmChannelResponse { interface SendDiscordDirectMessageOptions { config: DiscordNotificationConfig; message: string; - fetcher?: DiscordFetch; + fetcher: DiscordFetch; } const DISCORD_API_BASE_URL = "https://discord.com/api/v10"; diff --git a/plugin/types/ws.d.ts b/plugin/types/ws.d.ts new file mode 100644 index 0000000..b87451f --- /dev/null +++ b/plugin/types/ws.d.ts @@ -0,0 +1,21 @@ +declare module "ws" { + export type RawData = Buffer | ArrayBuffer | Buffer[] | string; + + export class WebSocket { + static readonly OPEN: number; + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + on(event: "message", listener: (data: RawData) => void): this; + on(event: "close", listener: (code: number, reason: Buffer) => void): this; + on(event: "error", listener: (error: Error) => void): this; + } + + export class WebSocketServer { + constructor(options: { host?: string; port: number }); + on(event: "error", listener: (error: Error) => void): this; + on(event: "listening", listener: () => void): this; + on(event: "connection", listener: (ws: WebSocket, req: import("http").IncomingMessage) => void): this; + close(callback?: () => void): void; + } +} diff --git a/tests/state-recovery.test.ts b/tests/state-recovery.test.ts index 5bcdbdd..bfa03d7 100644 --- a/tests/state-recovery.test.ts +++ b/tests/state-recovery.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createYonexusServerRuntime } from "../plugin/core/runtime.js"; +import { createMockNotificationService } from "../plugin/notifications/discord.js"; import { createYonexusServerStore, loadServerStore, @@ -90,6 +91,7 @@ describe("YNX-1105e: Server state recovery", () => { }, store, transport: firstTransport.transport, + notificationService: createMockNotificationService(), now: () => now }); @@ -145,6 +147,7 @@ describe("YNX-1105e: Server state recovery", () => { }, store, transport: secondTransport.transport, + notificationService: createMockNotificationService(), now: () => now }); diff --git a/tsconfig.json b/tsconfig.json index cf17390..658f108 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", - "rootDir": ".", + "rootDir": "..", "strict": true, "skipLibCheck": true, "esModuleInterop": true, @@ -15,7 +15,9 @@ }, "include": [ "plugin/**/*.ts", - "servers/**/*.ts" + "plugin/**/*.d.ts", + "servers/**/*.ts", + "../Yonexus.Protocol/src/**/*.ts" ], "exclude": [ "dist", From 59d5b26aff7cd5659c665991d3fc243dd8e4e324 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 10 Apr 2026 20:15:03 +0100 Subject: [PATCH 27/29] feat: wire rule registry and client-authenticated callback into server runtime - Add ruleRegistry and onClientAuthenticated options to YonexusServerRuntime - Dispatch rewritten rule messages (rule::sender::content) to rule registry - Guard onClientAuthenticated behind promoteToAuthenticated return value - Fix transport message handler: use tempConn directly when ws is in temp state, preventing stale _connections entry from causing promoteToAuthenticated to fail - Close competing temp connections with same identifier on promotion - Expose __yonexusServer on globalThis for cross-plugin communication - Remove auto-failure on admin notification miss; pairing stays pending Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 + plugin/core/runtime.ts | 45 ++++------ plugin/core/transport.ts | 40 ++++++--- plugin/index.ts | 165 +++++++++++++++++++++++++++++++++--- plugin/openclaw.plugin.json | 25 ++++-- scripts/install.mjs | 1 + 6 files changed, 218 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index 4d13a33..ef8d99a 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "description": "Yonexus.Server OpenClaw plugin scaffold", "type": "module", "main": "dist/plugin/index.js", + "openclaw": { + "extensions": ["./dist/Yonexus.Server/plugin/index.js"] + }, "files": [ "dist", "plugin", diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 68bf972..8f0bd58 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -47,12 +47,15 @@ import { type DiscordNotificationService } from "../notifications/discord.js"; import { safeErrorMessage } from "./logging.js"; +import type { ServerRuleRegistry } from "./rules.js"; export interface YonexusServerRuntimeOptions { config: YonexusServerConfig; store: YonexusServerStore; transport: ServerTransport; notificationService?: DiscordNotificationService; + ruleRegistry?: ServerRuleRegistry; + onClientAuthenticated?: (identifier: string) => void; now?: () => number; sweepIntervalMs?: number; } @@ -447,7 +450,7 @@ export class YonexusServerRuntime { ); record.recentHandshakeAttempts.push(now); - if (record.recentHandshakeAttempts.length > AUTH_MAX_ATTEMPTS_PER_WINDOW) { + if (record.recentHandshakeAttempts.length >= AUTH_MAX_ATTEMPTS_PER_WINDOW) { await this.triggerRePairRequired(connection, record, envelope.requestId, "rate_limited"); return; } @@ -543,7 +546,10 @@ export class YonexusServerRuntime { session.lastActivityAt = now; session.publicKey = publicKey; } - this.options.transport.promoteToAuthenticated(identifier, connection.ws); + const promoted = this.options.transport.promoteToAuthenticated(identifier, connection.ws); + if (promoted) { + this.options.onClientAuthenticated?.(identifier); + } this.options.transport.sendToConnection( { ...connection, identifier }, encodeBuiltin( @@ -613,6 +619,11 @@ export class YonexusServerRuntime { this.pairingService.markNotificationFailed(record); } + // Persist immediately so the pairing code is readable from disk (e.g. via CLI) + if (!reusePending) { + await this.persist(); + } + this.options.transport.sendToConnection( connection, encodeBuiltin( @@ -620,7 +631,7 @@ export class YonexusServerRuntime { { identifier: record.identifier, expiresAt: request.expiresAt, - ttlSeconds: this.pairingService.getRemainingTtl(record), + ttlSeconds: request.ttlSeconds, adminNotification: notified ? "sent" : "failed", codeDelivery: "out_of_band" }, @@ -628,22 +639,8 @@ export class YonexusServerRuntime { ) ) ); - - if (!notified) { - this.options.transport.sendToConnection( - connection, - encodeBuiltin( - buildPairFailed( - { - identifier: record.identifier, - reason: "admin_notification_failed" - }, - { requestId, timestamp: this.now() } - ) - ) - ); - this.pairingService.clearPairingState(record); - } + // Pairing remains pending regardless of notification status. + // The admin can retrieve the pairing code via the server CLI command. } private async handleHeartbeat( @@ -932,16 +929,8 @@ export class YonexusServerRuntime { const parsed = parseRuleMessage(raw); const rewritten = `${parsed.ruleIdentifier}::${senderIdentifier}::${parsed.content}`; - // TODO: Dispatch to registered rules via rule registry - // For now, just log the rewritten message - // this.ruleRegistry.dispatch(rewritten); - - // Update last activity session.lastActivityAt = this.now(); - - // Future: dispatch to rule registry - // eslint-disable-next-line @typescript-eslint/no-unused-vars - void rewritten; + this.options.ruleRegistry?.dispatch(rewritten); } catch (error) { // Malformed rule message this.options.transport.sendToConnection( diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index e5409b3..a7d01bd 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -186,6 +186,16 @@ export class YonexusServerTransport implements ServerTransport { this._connections.delete(identifier); } + // Also close any OTHER temp connections that claimed the same identifier. + // This handles the case where a second hello came in with the same identifier + // while the first was still in the temp/pairing phase. + for (const [otherWs, otherTemp] of this.tempConnections.entries()) { + if (otherWs !== ws && otherTemp.assignedIdentifier === identifier) { + otherWs.close(1008, "Connection replaced by new authenticated session"); + this.tempConnections.delete(otherWs); + } + } + // Remove from temp connections this.tempConnections.delete(ws); @@ -229,22 +239,24 @@ export class YonexusServerTransport implements ServerTransport { ws.on("message", (data: RawData) => { const message = data.toString("utf8"); - // Try to get identifier from temp connections first, then authenticated connections - let identifier: string | null = null; - const tempData = this.tempConnections.get(ws); - if (tempData) { - identifier = tempData.assignedIdentifier; - } - if (!identifier) { - for (const [id, conn] of this._connections) { - if (conn.ws === ws) { - identifier = id; - break; - } - } + // If this ws is still in temp state, use tempConn directly. + // Never fall through to _connections — it may hold a stale entry for the + // same identifier from a previously-authenticated session that hasn't + // finished closing yet, which would cause promoteToAuthenticated to receive + // the wrong WebSocket and silently fail. + if (this.tempConnections.has(ws)) { + this.options.onMessage(tempConn, message); + return; } - const connection = identifier ? this._connections.get(identifier) ?? tempConn : tempConn; + // ws has been promoted — find it in authenticated connections + let connection: ClientConnection = tempConn; + for (const [, conn] of this._connections) { + if (conn.ws === ws) { + connection = conn; + break; + } + } this.options.onMessage(connection, message); }); diff --git a/plugin/index.ts b/plugin/index.ts index eb2d894..33b02b8 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -30,30 +30,173 @@ export { type YonexusServerStore } from "./core/store.js"; +import path from "node:path"; +import fs from "node:fs"; +import { validateYonexusServerConfig } from "./core/config.js"; +import { createYonexusServerStore } from "./core/store.js"; +import { createServerTransport } from "./core/transport.js"; +import { createYonexusServerRuntime } from "./core/runtime.js"; +import { createServerRuleRegistry } from "./core/rules.js"; +import { encodeRuleMessage } from "../../Yonexus.Protocol/src/index.js"; +import type { ServerPersistenceData } from "./core/persistence.js"; + export interface YonexusServerPluginManifest { readonly name: "Yonexus.Server"; readonly version: string; readonly description: string; } -export interface YonexusServerPluginRuntime { - readonly hooks: readonly []; - readonly commands: readonly []; - readonly tools: readonly []; -} - const manifest: YonexusServerPluginManifest = { name: "Yonexus.Server", version: "0.1.0", description: "Yonexus central hub plugin for cross-instance OpenClaw communication" }; -export function createYonexusServerPlugin(): YonexusServerPluginRuntime { - return { - hooks: [], - commands: [], - tools: [] +let _serverStarted = false; + +export function createYonexusServerPlugin(api: { + rootDir: string; + pluginConfig: unknown; + registrationMode?: string; // "full" (gateway) | "cli-metadata" | "setup-only" | "setup-runtime" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + registerCli?: (registrar: (ctx: { program: any }) => void, opts?: { commands?: string[] }) => void; +}): void { + const stateFilePath = path.join(api.rootDir, "state.json"); + + // Register CLI regardless of whether the gateway is already running. + // The CLI process is a separate invocation that reads from the persisted state file. + api.registerCli?.(({ program }) => { + const group = program + .command("yonexus-server") + .description("Yonexus.Server management"); + + group + .command("pair-code ") + .description("Show the pending pairing code for a device awaiting confirmation") + .action((identifier: string) => { + let raw: ServerPersistenceData; + try { + raw = JSON.parse(fs.readFileSync(stateFilePath, "utf8")) as ServerPersistenceData; + } catch { + console.error("Error: could not read server state. Is the gateway running?"); + process.exit(1); + } + + const client = raw.clients?.find((c) => c.identifier === identifier); + if (!client) { + console.error(`Error: identifier "${identifier}" not found in server registry.`); + process.exit(1); + } + if (client.pairingStatus !== "pending" || !client.pairingCode) { + const status = client.pairingStatus; + console.error(`Error: no pending pairing for "${identifier}" (status: ${status}).`); + process.exit(1); + } + if (client.pairingExpiresAt && Math.floor(Date.now() / 1000) > client.pairingExpiresAt) { + console.error(`Error: pairing for "${identifier}" has expired.`); + process.exit(1); + } + + const expiresIn = client.pairingExpiresAt + ? Math.max(0, client.pairingExpiresAt - Math.floor(Date.now() / 1000)) + : 0; + const mm = String(Math.floor(expiresIn / 60)).padStart(2, "0"); + const ss = String(expiresIn % 60).padStart(2, "0"); + + console.log(`Identifier : ${client.identifier}`); + console.log(`Pairing code : ${client.pairingCode}`); + console.log(`Expires in : ${mm}m ${ss}s`); + }); + + group + .command("list-pending") + .description("List all identifiers with a pending pairing code") + .action(() => { + let raw: ServerPersistenceData; + try { + raw = JSON.parse(fs.readFileSync(stateFilePath, "utf8")) as ServerPersistenceData; + } catch { + console.error("Error: could not read server state. Is the gateway running?"); + process.exit(1); + } + + const now = Math.floor(Date.now() / 1000); + const pending = (raw.clients ?? []).filter( + (c) => c.pairingStatus === "pending" && c.pairingCode && (!c.pairingExpiresAt || now <= c.pairingExpiresAt) + ); + + if (pending.length === 0) { + console.log("No pending pairings."); + return; + } + for (const c of pending) { + const expiresIn = c.pairingExpiresAt ? Math.max(0, c.pairingExpiresAt - now) : 0; + const mm = String(Math.floor(expiresIn / 60)).padStart(2, "0"); + const ss = String(expiresIn % 60).padStart(2, "0"); + console.log(` ${c.identifier} (expires in ${mm}m ${ss}s)`); + } + }); + }, { commands: ["yonexus-server"] }); + + if (_serverStarted) return; + _serverStarted = true; + + const config = validateYonexusServerConfig(api.pluginConfig); + const store = createYonexusServerStore(stateFilePath); + + const ruleRegistry = createServerRuleRegistry(); + const onClientAuthenticatedCallbacks: Array<(identifier: string) => void> = []; + + let runtimeRef: ReturnType | null = null; + const transport = createServerTransport({ + config, + onMessage: (conn, msg) => { + runtimeRef?.handleMessage(conn, msg).catch((err: unknown) => { + console.error("[yonexus-server] message handler error:", err); + }); + }, + onDisconnect: (identifier) => { + if (identifier && runtimeRef) { + runtimeRef.handleDisconnect(identifier); + } + } + }); + + // Expose registry and helpers for other plugins loaded in the same process + (globalThis as Record)["__yonexusServer"] = { + ruleRegistry, + sendRule: (identifier: string, ruleId: string, content: string): boolean => + transport.send(identifier, encodeRuleMessage(ruleId, content)), + onClientAuthenticated: onClientAuthenticatedCallbacks }; + + const runtime = createYonexusServerRuntime({ + config, + store, + transport, + ruleRegistry, + onClientAuthenticated: (identifier) => { + for (const cb of onClientAuthenticatedCallbacks) cb(identifier); + } + }); + runtimeRef = runtime; + + const shutdown = (): void => { + runtime.stop().catch((err: unknown) => { + console.error("[yonexus-server] shutdown error:", err); + }); + }; + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); + + runtime.start().catch((err: unknown) => { + // EADDRINUSE means the gateway is already running (e.g. this is a CLI invocation). + // Any other error is a real problem worth logging. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code !== "EADDRINUSE") { + console.error("[yonexus-server] failed to start:", err); + } + }); } export default createYonexusServerPlugin; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index def1da4..3cef357 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -1,15 +1,24 @@ { + "id": "yonexus-server", "name": "Yonexus.Server", "version": "0.1.0", "description": "Yonexus central hub plugin for cross-instance OpenClaw communication", - "entry": "dist/plugin/index.js", + "entry": "./dist/Yonexus.Server/plugin/index.js", "permissions": [], - "config": { - "followerIdentifiers": [], - "notifyBotToken": "", - "adminUserId": "", - "listenHost": "0.0.0.0", - "listenPort": 8787, - "publicWsUrl": "" + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "followerIdentifiers": { + "type": "array", + "items": { "type": "string" } + }, + "notifyBotToken": { "type": "string" }, + "adminUserId": { "type": "string" }, + "listenHost": { "type": "string" }, + "listenPort": { "type": "number" }, + "publicWsUrl": { "type": "string" } + }, + "required": ["followerIdentifiers", "notifyBotToken", "adminUserId", "listenPort"] } } diff --git a/scripts/install.mjs b/scripts/install.mjs index ff3f7dd..9fe3a18 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -29,6 +29,7 @@ if (mode === "install") { fs.rmSync(targetDir, { recursive: true, force: true }); fs.cpSync(sourceDist, path.join(targetDir, "dist"), { recursive: true }); fs.copyFileSync(path.join(repoRoot, "plugin", "openclaw.plugin.json"), path.join(targetDir, "openclaw.plugin.json")); + fs.copyFileSync(path.join(repoRoot, "package.json"), path.join(targetDir, "package.json")); console.log(`Installed ${pluginName} to ${targetDir}`); process.exit(0); } From 07c670c27271feddaeb69b1ce5eb80c7cc9602a0 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 10 Apr 2026 20:41:32 +0100 Subject: [PATCH 28/29] fix: migrate startup guard and shared state to globalThis Module-level _serverStarted / ruleRegistry / onClientAuthenticatedCallbacks reset on hot-reload (new VM context). After hot-reload the second runtime attempt would hit EADDRINUSE (silently swallowed) while __yonexusServer was overwritten to point at a transport that never started, making every sendRule() return false. - Replace let _serverStarted with _G["_yonexusServerStarted"] - Store ruleRegistry and onClientAuthenticatedCallbacks under globalThis keys, initialising only when absent - Store transport under _G["_yonexusServerTransport"]; sendRule closure reads it from globalThis instead of a module-local capture - Re-write __yonexusServer every register() call (updated closures), but skip runtime.start() when the globalThis flag is already set Co-Authored-By: Claude Sonnet 4.6 --- plugin/index.ts | 50 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 33b02b8..dea52aa 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -34,12 +34,18 @@ import path from "node:path"; import fs from "node:fs"; import { validateYonexusServerConfig } from "./core/config.js"; import { createYonexusServerStore } from "./core/store.js"; -import { createServerTransport } from "./core/transport.js"; +import { createServerTransport, type ServerTransport } from "./core/transport.js"; import { createYonexusServerRuntime } from "./core/runtime.js"; -import { createServerRuleRegistry } from "./core/rules.js"; +import { createServerRuleRegistry, YonexusServerRuleRegistry } from "./core/rules.js"; import { encodeRuleMessage } from "../../Yonexus.Protocol/src/index.js"; import type { ServerPersistenceData } from "./core/persistence.js"; +const _G = globalThis as Record; +const _STARTED_KEY = "_yonexusServerStarted"; +const _TRANSPORT_KEY = "_yonexusServerTransport"; +const _REGISTRY_KEY = "_yonexusServerRegistry"; +const _CALLBACKS_KEY = "_yonexusServerOnAuthCallbacks"; + export interface YonexusServerPluginManifest { readonly name: "Yonexus.Server"; readonly version: string; @@ -52,8 +58,6 @@ const manifest: YonexusServerPluginManifest = { description: "Yonexus central hub plugin for cross-instance OpenClaw communication" }; -let _serverStarted = false; - export function createYonexusServerPlugin(api: { rootDir: string; pluginConfig: unknown; @@ -138,15 +142,34 @@ export function createYonexusServerPlugin(api: { }); }, { commands: ["yonexus-server"] }); - if (_serverStarted) return; - _serverStarted = true; + // 1. Ensure shared state survives hot-reload — only initialise when absent + if (!(_G[_REGISTRY_KEY] instanceof YonexusServerRuleRegistry)) { + _G[_REGISTRY_KEY] = createServerRuleRegistry(); + } + if (!Array.isArray(_G[_CALLBACKS_KEY])) { + _G[_CALLBACKS_KEY] = []; + } + + const ruleRegistry = _G[_REGISTRY_KEY] as YonexusServerRuleRegistry; + const onClientAuthenticatedCallbacks = _G[_CALLBACKS_KEY] as Array<(identifier: string) => void>; + + // 2. Refresh the cross-plugin API object every call so that sendRule closure + // always reads the live transport from globalThis. + _G["__yonexusServer"] = { + ruleRegistry, + sendRule: (identifier: string, ruleId: string, content: string): boolean => + (_G[_TRANSPORT_KEY] as ServerTransport | undefined)?.send(identifier, encodeRuleMessage(ruleId, content)) ?? false, + onClientAuthenticated: onClientAuthenticatedCallbacks + }; + + // 3. Start the runtime only once — the globalThis flag survives hot-reload + if (_G[_STARTED_KEY]) return; + _G[_STARTED_KEY] = true; const config = validateYonexusServerConfig(api.pluginConfig); const store = createYonexusServerStore(stateFilePath); - const ruleRegistry = createServerRuleRegistry(); - const onClientAuthenticatedCallbacks: Array<(identifier: string) => void> = []; - + // runtimeRef is local; transport is stored in globalThis so sendRule closures stay valid let runtimeRef: ReturnType | null = null; const transport = createServerTransport({ config, @@ -161,14 +184,7 @@ export function createYonexusServerPlugin(api: { } } }); - - // Expose registry and helpers for other plugins loaded in the same process - (globalThis as Record)["__yonexusServer"] = { - ruleRegistry, - sendRule: (identifier: string, ruleId: string, content: string): boolean => - transport.send(identifier, encodeRuleMessage(ruleId, content)), - onClientAuthenticated: onClientAuthenticatedCallbacks - }; + _G[_TRANSPORT_KEY] = transport; const runtime = createYonexusServerRuntime({ config, From a8748f8c5526dac548dea49c376e85376d0c8765 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 10 Apr 2026 21:58:59 +0100 Subject: [PATCH 29/29] fix: globalThis --- protocol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protocol b/protocol index 9232aa7..2611304 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 9232aa7c1755adda6990a5a2f6c7c1a114285a73 +Subproject commit 26113040844cc6804e6a2b617d0c9ce1cbdb92df