Compare commits
17 Commits
d8290c0aa7
...
4f4c6bf993
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f4c6bf993 | |||
| 35d787be04 | |||
| b8008d9302 | |||
| 25e1867adf | |||
| 988170dcf6 | |||
| 4f20ec3fd7 | |||
| 075fcb7974 | |||
| ba007ebd59 | |||
| 83f6195c1f | |||
| a05b226056 | |||
| cd09fe6043 | |||
| f7c7531385 | |||
| b44a4cae66 | |||
| c5287fa474 | |||
| bc1a002a8c | |||
| 3ec57ce199 | |||
| ac128d3827 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
146
README.md
146
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": "<discord-bot-token>",
|
||||
"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<boolean>
|
||||
sendRuleMessageToClient(identifier: string, ruleIdentifier: string, content: string): Promise<boolean>
|
||||
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`
|
||||
|
||||
1318
package-lock.json
generated
Normal file
1318
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"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",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
0
plugin/commands/.gitkeep
Normal file
0
plugin/commands/.gitkeep
Normal file
0
plugin/core/.gitkeep
Normal file
0
plugin/core/.gitkeep
Normal file
104
plugin/core/config.ts
Normal file
104
plugin/core/config.ts
Normal 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 && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||
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
|
||||
};
|
||||
}
|
||||
41
plugin/core/logging.ts
Normal file
41
plugin/core/logging.ts
Normal file
@@ -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 `<redacted:${label}:empty>`;
|
||||
}
|
||||
|
||||
if (value.length <= visibleEdge * 2) {
|
||||
return `<redacted:${label}:${value.length}>`;
|
||||
}
|
||||
|
||||
return `${value.slice(0, visibleEdge)}…${value.slice(-visibleEdge)} <redacted:${label}:${value.length}>`;
|
||||
}
|
||||
|
||||
export function safeErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
253
plugin/core/persistence.ts
Normal file
253
plugin/core/persistence.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Yonexus Server - Persistence Types
|
||||
*
|
||||
* Defines the persistent record structures for client registry and state management.
|
||||
* Based on PLAN.md section 6 and 12.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Client pairing status
|
||||
*/
|
||||
export type PairingStatus = "unpaired" | "pending" | "paired" | "revoked";
|
||||
|
||||
/**
|
||||
* Client liveness status
|
||||
*/
|
||||
export type ClientLivenessStatus = "online" | "offline" | "unstable";
|
||||
|
||||
/**
|
||||
* Pairing notification delivery status
|
||||
*/
|
||||
export type PairingNotifyStatus = "pending" | "sent" | "failed";
|
||||
|
||||
/**
|
||||
* Security window entry for nonce tracking
|
||||
*/
|
||||
export interface NonceEntry {
|
||||
/** The nonce value */
|
||||
readonly nonce: string;
|
||||
/** UTC unix timestamp when the nonce was used */
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security window entry for handshake attempt tracking
|
||||
*/
|
||||
export interface HandshakeAttemptEntry {
|
||||
/** UTC unix timestamp of the attempt */
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent client record stored by Yonexus.Server
|
||||
*
|
||||
* This structure represents the durable trust state for a client.
|
||||
* Rolling security windows (recentNonces, recentHandshakeAttempts) may be
|
||||
* cleared on server restart as per v1 semantics.
|
||||
*/
|
||||
export interface ClientRecord {
|
||||
/** Unique client identifier */
|
||||
readonly identifier: string;
|
||||
|
||||
/** Client's public key (Ed25519 or other) - stored after pairing */
|
||||
publicKey?: string;
|
||||
|
||||
/** Shared secret issued after successful pairing */
|
||||
secret?: string;
|
||||
|
||||
/** Current pairing status */
|
||||
pairingStatus: PairingStatus;
|
||||
|
||||
/** Pairing code (only valid when pairingStatus is "pending") */
|
||||
pairingCode?: string;
|
||||
|
||||
/** Pairing expiration timestamp (UTC unix seconds) */
|
||||
pairingExpiresAt?: number;
|
||||
|
||||
/** When the pairing notification was sent (UTC unix seconds) */
|
||||
pairingNotifiedAt?: number;
|
||||
|
||||
/** Status of the pairing notification delivery */
|
||||
pairingNotifyStatus?: PairingNotifyStatus;
|
||||
|
||||
/** Current liveness status (may be stale on restart) */
|
||||
status: ClientLivenessStatus;
|
||||
|
||||
/** Last heartbeat received timestamp (UTC unix seconds) */
|
||||
lastHeartbeatAt?: number;
|
||||
|
||||
/** Last successful authentication timestamp (UTC unix seconds) */
|
||||
lastAuthenticatedAt?: number;
|
||||
|
||||
/**
|
||||
* Recent nonces used in authentication attempts.
|
||||
* This is a rolling window that may be cleared on restart.
|
||||
*/
|
||||
recentNonces: NonceEntry[];
|
||||
|
||||
/**
|
||||
* Recent handshake attempt timestamps.
|
||||
* This is a rolling window that may be cleared on restart.
|
||||
*/
|
||||
recentHandshakeAttempts: number[];
|
||||
|
||||
/** Record creation timestamp (UTC unix seconds) */
|
||||
readonly createdAt: number;
|
||||
|
||||
/** Record last update timestamp (UTC unix seconds) */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session state (not persisted)
|
||||
*
|
||||
* Represents an active or pending WebSocket connection.
|
||||
*/
|
||||
export interface ClientSession {
|
||||
/** Client identifier */
|
||||
readonly identifier: string;
|
||||
|
||||
/** WebSocket connection instance */
|
||||
readonly socket: unknown; // Will be typed as WebSocket when implementing transport
|
||||
|
||||
/** Public key presented during hello, before pairing completes */
|
||||
publicKey?: string;
|
||||
|
||||
/** Whether the client is currently authenticated */
|
||||
isAuthenticated: boolean;
|
||||
|
||||
/** Session start timestamp (UTC unix seconds) */
|
||||
readonly connectedAt: number;
|
||||
|
||||
/** Last activity timestamp (UTC unix seconds) */
|
||||
lastActivityAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server registry state
|
||||
*
|
||||
* Contains both persistent and in-memory state for all clients.
|
||||
*/
|
||||
export interface ServerRegistry {
|
||||
/** Persistent client records keyed by identifier */
|
||||
clients: Map<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;
|
||||
}
|
||||
89
plugin/core/rules.ts
Normal file
89
plugin/core/rules.ts
Normal file
@@ -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<string, ServerRuleProcessor>();
|
||||
|
||||
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();
|
||||
}
|
||||
964
plugin/core/runtime.ts
Normal file
964
plugin/core/runtime.ts
Normal file
@@ -0,0 +1,964 @@
|
||||
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,
|
||||
buildDisconnectNotice,
|
||||
buildError,
|
||||
buildHeartbeatAck,
|
||||
buildStatusUpdate,
|
||||
buildHelloAck,
|
||||
buildPairFailed,
|
||||
buildPairRequest,
|
||||
buildPairSuccess,
|
||||
buildRePairRequired,
|
||||
CodecError,
|
||||
decodeBuiltin,
|
||||
encodeBuiltin,
|
||||
encodeRuleMessage,
|
||||
extractAuthRequestSigningInput,
|
||||
isBuiltinMessage,
|
||||
isTimestampFresh,
|
||||
isValidAuthNonce,
|
||||
parseRuleMessage,
|
||||
type AuthRequestPayload,
|
||||
type HeartbeatPayload
|
||||
} 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 { 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";
|
||||
import {
|
||||
createDiscordNotificationService,
|
||||
type DiscordNotificationService
|
||||
} from "../notifications/discord.js";
|
||||
import { safeErrorMessage } from "./logging.js";
|
||||
|
||||
export interface YonexusServerRuntimeOptions {
|
||||
config: YonexusServerConfig;
|
||||
store: YonexusServerStore;
|
||||
transport: ServerTransport;
|
||||
now?: () => number;
|
||||
sweepIntervalMs?: 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 readonly pairingService: PairingService;
|
||||
private readonly notificationService: DiscordNotificationService;
|
||||
private readonly sweepIntervalMs: number;
|
||||
private sweepTimer: NodeJS.Timeout | null = null;
|
||||
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()
|
||||
};
|
||||
this.sweepIntervalMs = options.sweepIntervalMs ?? 30_000;
|
||||
this.pairingService = createPairingService({ now: this.now });
|
||||
this.notificationService = createDiscordNotificationService({
|
||||
botToken: options.config.notifyBotToken,
|
||||
adminUserId: options.config.adminUserId
|
||||
});
|
||||
}
|
||||
|
||||
get state(): ServerLifecycleState {
|
||||
return {
|
||||
isStarted: this.started,
|
||||
registry: this.registry
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
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.startSweepTimer();
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopSweepTimer();
|
||||
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<void> {
|
||||
if (!isBuiltinMessage(raw)) {
|
||||
// Handle rule message - rewrite and dispatch
|
||||
await this.handleRuleMessage(connection, raw);
|
||||
return;
|
||||
}
|
||||
|
||||
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>);
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.type === "pair_confirm") {
|
||||
await this.handlePairConfirm(
|
||||
connection,
|
||||
envelope as BuiltinEnvelope<"pair_confirm", PairConfirmPayload>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.type === "auth_request") {
|
||||
await this.handleAuthRequest(
|
||||
connection,
|
||||
envelope as BuiltinEnvelope<"auth_request", AuthRequestPayload>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.type === "heartbeat") {
|
||||
await this.handleHeartbeat(
|
||||
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(
|
||||
connection: ClientConnection,
|
||||
envelope: BuiltinEnvelope<"hello", HelloPayload>
|
||||
): Promise<void> {
|
||||
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.assignIdentifierToTemp(connection.ws, helloIdentifier);
|
||||
this.registry.sessions.set(helloIdentifier, {
|
||||
identifier: helloIdentifier,
|
||||
socket: connection.ws,
|
||||
isAuthenticated: false,
|
||||
connectedAt: connection.connectedAt,
|
||||
lastActivityAt: this.now(),
|
||||
publicKey: payload.publicKey?.trim() || undefined
|
||||
});
|
||||
|
||||
const nextAction = this.determineNextAction(record);
|
||||
this.options.transport.sendToConnection(
|
||||
{ ...connection, identifier: helloIdentifier },
|
||||
encodeBuiltin(
|
||||
buildHelloAck(
|
||||
{
|
||||
identifier: helloIdentifier,
|
||||
nextAction
|
||||
},
|
||||
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
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<void> {
|
||||
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.assignIdentifierToTemp(connection.ws, identifier);
|
||||
}
|
||||
|
||||
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 async handleAuthRequest(
|
||||
connection: ClientConnection,
|
||||
envelope: BuiltinEnvelope<"auth_request", AuthRequestPayload>
|
||||
): Promise<void> {
|
||||
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.promoteToAuthenticated(identifier, connection.ws);
|
||||
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";
|
||||
}
|
||||
|
||||
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 beginPairing(options: {
|
||||
record: ClientRecord;
|
||||
connection: ClientConnection;
|
||||
requestId?: string;
|
||||
reusePending?: boolean;
|
||||
}): Promise<void> {
|
||||
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);
|
||||
|
||||
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 handleHeartbeat(
|
||||
connection: ClientConnection,
|
||||
envelope: BuiltinEnvelope<"heartbeat", HeartbeatPayload>
|
||||
): Promise<void> {
|
||||
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 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<void> {
|
||||
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,
|
||||
requestId: string | undefined,
|
||||
reason: "nonce_collision" | "rate_limited"
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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(
|
||||
options: YonexusServerRuntimeOptions
|
||||
): YonexusServerRuntime {
|
||||
return new YonexusServerRuntime(options);
|
||||
}
|
||||
181
plugin/core/store.ts
Normal file
181
plugin/core/store.ts
Normal file
@@ -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<string, ClientRecord>;
|
||||
}
|
||||
|
||||
export interface YonexusServerStore {
|
||||
readonly filePath: string;
|
||||
load(): Promise<ServerStoreLoadResult>;
|
||||
save(clients: Iterable<ClientRecord>): Promise<void>;
|
||||
}
|
||||
|
||||
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<ServerStoreLoadResult> {
|
||||
try {
|
||||
const raw = await readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as ServerPersistenceData;
|
||||
assertPersistenceDataShape(parsed, filePath);
|
||||
|
||||
const clients = new Map<string, ClientRecord>();
|
||||
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<ClientRecord>
|
||||
): Promise<void> {
|
||||
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<ServerPersistenceData>;
|
||||
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<SerializedClientRecord>;
|
||||
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"
|
||||
);
|
||||
}
|
||||
283
plugin/core/transport.ts
Normal file
283
plugin/core/transport.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { WebSocketServer, WebSocket, RawData } from "ws";
|
||||
import type { YonexusServerConfig } from "./config.js";
|
||||
import { safeErrorMessage } from "./logging.js";
|
||||
|
||||
export interface ClientConnection {
|
||||
readonly identifier: string | null;
|
||||
readonly ws: WebSocket;
|
||||
readonly connectedAt: number;
|
||||
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<string, ClientConnection>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
send(identifier: string, message: string): boolean;
|
||||
sendToConnection(connection: ClientConnection, message: string): boolean;
|
||||
broadcast(message: string): void;
|
||||
closeConnection(identifier: string, code?: number, reason?: 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;
|
||||
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<string, ClientConnection>();
|
||||
private tempConnections = new Map<WebSocket, TempConnection>();
|
||||
private options: ServerTransportOptions;
|
||||
private _isRunning = false;
|
||||
|
||||
constructor(options: ServerTransportOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
get connections(): ReadonlyMap<string, ClientConnection> {
|
||||
return this._connections;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
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<void> {
|
||||
if (!this._isRunning || !this.wss) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close all authenticated connections
|
||||
for (const conn of this._connections.values()) {
|
||||
conn.ws.close(1000, "Server shutting down");
|
||||
}
|
||||
this._connections.clear();
|
||||
|
||||
// Close all temp connections
|
||||
for (const temp of this.tempConnections.values()) {
|
||||
temp.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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
broadcast(message: string): void {
|
||||
for (const conn of this._connections.values()) {
|
||||
if (conn.isAuthenticated) {
|
||||
this.sendToConnection(conn, 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;
|
||||
}
|
||||
|
||||
promoteToAuthenticated(identifier: string, ws: WebSocket): boolean {
|
||||
// Verify the connection exists in temp connections
|
||||
const tempConn = this.tempConnections.get(ws);
|
||||
if (!tempConn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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: tempConn.connectedAt,
|
||||
isAuthenticated: true
|
||||
};
|
||||
|
||||
this._connections.set(identifier, conn);
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnection(ws: WebSocket, _req: import("http").IncomingMessage): void {
|
||||
// Store as temp connection until authenticated
|
||||
this.tempConnections.set(ws, {
|
||||
ws,
|
||||
connectedAt: Math.floor(Date.now() / 1000),
|
||||
assignedIdentifier: null
|
||||
});
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 authenticated 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("[Yonexus.Server] WebSocket error:", safeErrorMessage(error));
|
||||
});
|
||||
|
||||
if (this.options.onConnect) {
|
||||
this.options.onConnect(null, ws);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createServerTransport(options: ServerTransportOptions): ServerTransport {
|
||||
return new YonexusServerTransport(options);
|
||||
}
|
||||
42
plugin/crypto/utils.ts
Normal file
42
plugin/crypto/utils.ts
Normal file
@@ -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;
|
||||
0
plugin/hooks/.gitkeep
Normal file
0
plugin/hooks/.gitkeep
Normal file
@@ -0,0 +1,99 @@
|
||||
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 {
|
||||
SERVER_PERSISTENCE_VERSION,
|
||||
YonexusServerStoreError,
|
||||
YonexusServerStoreCorruptionError,
|
||||
createYonexusServerStore,
|
||||
loadServerStore,
|
||||
saveServerStore,
|
||||
type ServerStoreLoadResult,
|
||||
type YonexusServerStore
|
||||
} from "./core/store.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 {
|
||||
createServerTransport,
|
||||
YonexusServerTransport,
|
||||
type ServerTransport,
|
||||
type ServerTransportOptions,
|
||||
type ClientConnection,
|
||||
type MessageHandler,
|
||||
type ConnectionHandler,
|
||||
type DisconnectionHandler
|
||||
} from "./core/transport.js";
|
||||
export {
|
||||
createYonexusServerRuntime,
|
||||
YonexusServerRuntime,
|
||||
type YonexusServerRuntimeOptions,
|
||||
type ServerLifecycleState
|
||||
} from "./core/runtime.js";
|
||||
export {
|
||||
createServerRuleRegistry,
|
||||
YonexusServerRuleRegistry,
|
||||
ServerRuleRegistryError,
|
||||
type ServerRuleRegistry,
|
||||
type ServerRuleProcessor
|
||||
} from "./core/rules.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 };
|
||||
|
||||
103
plugin/notifications/discord.ts
Normal file
103
plugin/notifications/discord.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
export interface DiscordNotificationService {
|
||||
/**
|
||||
* Send a pairing code notification to the admin user.
|
||||
* @returns Whether the notification was sent successfully
|
||||
*/
|
||||
sendPairingNotification(request: PairingRequest): Promise<boolean>;
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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**",
|
||||
"",
|
||||
`**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<boolean> {
|
||||
console.log("[Yonexus.Server] Mock pairing notification:");
|
||||
console.log(` Identifier: ${request.identifier}`);
|
||||
console.log(` Pairing Code: ${redactPairingCode(request.pairingCode)}`);
|
||||
console.log(` Success: ${shouldSucceed}`);
|
||||
return shouldSucceed;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
190
plugin/services/pairing.ts
Normal file
190
plugin/services/pairing.ts
Normal file
@@ -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);
|
||||
}
|
||||
0
plugin/tools/.gitkeep
Normal file
0
plugin/tools/.gitkeep
Normal 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
0
servers/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
102
tests/pairing-and-rules.test.ts
Normal file
102
tests/pairing-and-rules.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
486
tests/pairing-auth-liveness.test.ts
Normal file
486
tests/pairing-auth-liveness.test.ts
Normal file
@@ -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<typeof createClientRecord>,
|
||||
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 };
|
||||
}
|
||||
390
tests/runtime-flow.test.ts
Normal file
390
tests/runtime-flow.test.ts
Normal file
@@ -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<ClientRecord>) => {
|
||||
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<object, string>();
|
||||
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<typeof vi.fn>)).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();
|
||||
}
|
||||
});
|
||||
});
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node"
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user