Compare commits

...

3 Commits

Author SHA1 Message Date
nav
bc1a002a8c 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
2026-04-08 20:20:11 +00:00
nav
3ec57ce199 feat: add server config validation 2026-04-08 20:03:28 +00:00
nav
ac128d3827 feat: scaffold yonexus server plugin 2026-04-08 19:33:32 +00:00
13 changed files with 507 additions and 0 deletions

27
package.json Normal file
View File

@@ -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"
}
}

0
plugin/commands/.gitkeep Normal file
View File

0
plugin/core/.gitkeep Normal file
View File

104
plugin/core/config.ts Normal file
View File

@@ -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<string, unknown> | 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
};
}

250
plugin/core/persistence.ts Normal file
View File

@@ -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<string, ClientRecord>;
/** Active WebSocket sessions keyed by identifier */
sessions: Map<string, ClientSession>;
}
/**
* Serialized form of ClientRecord for JSON persistence
*/
export interface SerializedClientRecord {
identifier: string;
publicKey?: string;
secret?: string;
pairingStatus: PairingStatus;
pairingCode?: string;
pairingExpiresAt?: number;
pairingNotifiedAt?: number;
pairingNotifyStatus?: PairingNotifyStatus;
status: ClientLivenessStatus;
lastHeartbeatAt?: number;
lastAuthenticatedAt?: number;
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;
}

0
plugin/hooks/.gitkeep Normal file
View File

View File

@@ -0,0 +1,50 @@
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";
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 };

View File

@@ -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": ""
}
}

0
plugin/tools/.gitkeep Normal file
View File

View File

@@ -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 <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}`);

0
servers/.gitkeep Normal file
View File

0
skills/.gitkeep Normal file
View File

24
tsconfig.json Normal file
View File

@@ -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"
]
}