Compare commits

...

43 Commits

Author SHA1 Message Date
operator
0fb9be9dee fix: allow empty followerIdentifiers to avoid blocking openclaw CLI
An empty followerIdentifiers array caused a fatal validation error during
plugin registration, which prevented all openclaw CLI commands from running.
2026-04-16 14:58:36 +00:00
nav
b571180b89 fix: register in plugins.load.paths and plugins.allow on install 2026-04-16 10:40:13 +00:00
nav
108590443c fix: install to plugins/yonexus-server (match plugin id) 2026-04-16 10:38:29 +00:00
nav
ea764f637e refactor: replace Yonexus.Client dependency with Protocol crypto
Server no longer needs Yonexus.Client at build or runtime.
verifySignature, generateKeyPair, signMessage now imported from Protocol.
2026-04-16 10:37:01 +00:00
h z
6bfa0f3f28 Merge pull request 'dev/2026-04-08' (#1) from dev/2026-04-08 into main
Reviewed-on: #1
2026-04-13 09:34:21 +00:00
a8748f8c55 fix: globalThis 2026-04-10 21:58:59 +01:00
07c670c272 fix: migrate startup guard and shared state to globalThis
Module-level _serverStarted / ruleRegistry / onClientAuthenticatedCallbacks
reset on hot-reload (new VM context). After hot-reload the second runtime
attempt would hit EADDRINUSE (silently swallowed) while __yonexusServer
was overwritten to point at a transport that never started, making every
sendRule() return false.

- Replace let _serverStarted with _G["_yonexusServerStarted"]
- Store ruleRegistry and onClientAuthenticatedCallbacks under globalThis
  keys, initialising only when absent
- Store transport under _G["_yonexusServerTransport"]; sendRule closure
  reads it from globalThis instead of a module-local capture
- Re-write __yonexusServer every register() call (updated closures),
  but skip runtime.start() when the globalThis flag is already set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:41:32 +01:00
59d5b26aff feat: wire rule registry and client-authenticated callback into server runtime
- Add ruleRegistry and onClientAuthenticated options to YonexusServerRuntime
- Dispatch rewritten rule messages (rule::sender::content) to rule registry
- Guard onClientAuthenticated behind promoteToAuthenticated return value
- Fix transport message handler: use tempConn directly when ws is in temp state,
  preventing stale _connections entry from causing promoteToAuthenticated to fail
- Close competing temp connections with same identifier on promotion
- Expose __yonexusServer on globalThis for cross-plugin communication
- Remove auto-failure on admin notification miss; pairing stays pending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:15:03 +01:00
nav
31f41cb49b Fix strict TypeScript checks for server 2026-04-09 04:38:07 +00:00
nav
2972c4750e feat(server): wire Discord DM pairing notifications 2026-04-09 04:06:06 +00:00
nav
b67166fd12 test: cover server restart session recovery 2026-04-09 03:33:09 +00:00
nav
e038fb7666 test: cover malformed hello runtime flow 2026-04-09 03:02:31 +00:00
nav
9bd62e5ee9 test: cover connection failure edge cases 2026-04-09 02:04:06 +00:00
nav
5bda184a8f test: cover server recovery scenarios 2026-04-09 01:32:49 +00:00
nav
3c760fc0f4 test: cover unauth rule + heartbeat failures 2026-04-09 01:19:13 +00:00
nav
0717b204f1 test: expand auth failure coverage 2026-04-09 01:13:44 +00:00
nav
35972981d3 test: add auth failure path coverage 2026-04-09 01:04:48 +00:00
nav
4f4c6bf993 test: cover server runtime flow 2026-04-09 00:42:32 +00:00
nav
35d787be04 test(server): add auth and liveness coverage 2026-04-09 00:36:37 +00:00
nav
b8008d9302 test: add server unit test coverage 2026-04-09 00:03:33 +00:00
nav
25e1867adf docs: flesh out server readme 2026-04-08 23:32:33 +00:00
nav
988170dcf6 YNX-1003: Implement single-identifier single-active-connection policy
- Refactor transport to track temp connections separately from authenticated
- Add assignIdentifierToTemp() for hello phase (pre-auth)
- Add promoteToAuthenticated() that closes old connection only after new one auths
- Add removeTempConnection() for cleanup on auth failure
- Update runtime to use new API: assignIdentifierToTemp() on hello, promoteToAuthenticated() on auth_success

This prevents an attacker from kicking an authenticated connection with just a hello message.
2026-04-08 23:24:33 +00:00
nav
4f20ec3fd7 Improve transport safety and log redaction 2026-04-08 23:03:54 +00:00
nav
075fcb7974 feat: add server liveness sweep and rule registry 2026-04-08 22:39:49 +00:00
nav
ba007ebd59 Handle heartbeat builtin messages 2026-04-08 22:35:02 +00:00
nav
83f6195c1f feat: validate yonexus auth requests 2026-04-08 22:04:49 +00:00
nav
a05b226056 feat: implement server pairing confirmation flow 2026-04-08 21:38:43 +00:00
nav
cd09fe6043 feat(server): add pairing service and notify stub 2026-04-08 21:34:46 +00:00
nav
f7c7531385 Add server runtime and hello handshake 2026-04-08 21:13:16 +00:00
nav
b44a4cae66 Add server WebSocket transport 2026-04-08 21:05:03 +00:00
nav
c5287fa474 feat(server): add registry persistence store 2026-04-08 20:33:25 +00:00
nav
bc1a002a8c feat(server): add persistence types and ClientRecord structure
- Add ClientRecord, ClientSession, ServerRegistry interfaces
- Add serialization helpers for persistent storage
- Add state check functions (isPairable, canAuthenticate, etc.)
- Export persistence types from plugin index.ts
2026-04-08 20:20:11 +00:00
nav
3ec57ce199 feat: add server config validation 2026-04-08 20:03:28 +00:00
nav
ac128d3827 feat: scaffold yonexus server plugin 2026-04-08 19:33:32 +00:00
nav
d8290c0aa7 create initial project skeleton 2026-04-01 18:11:04 +00:00
nav
7673969176 add development conventions 2026-04-01 01:58:16 +00:00
nav
998310e971 add implementation task breakdown 2026-04-01 01:56:30 +00:00
nav
162312d16c add manifest and install plan 2026-04-01 01:53:20 +00:00
nav
b64d87c532 add scaffold plan 2026-04-01 01:38:34 +00:00
nav
741c993214 add project structure document 2026-04-01 01:36:05 +00:00
nav
871fe94318 note protocol submodule in plan 2026-04-01 01:21:33 +00:00
nav
d20c3b46ab add Yonexus.Protocol as submodule 2026-04-01 01:19:57 +00:00
root
23969afa80 add project plan 2026-04-01 01:08:12 +00:00
41 changed files with 8102 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
*.log

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "protocol"]
path = protocol
url = https://git.hangman-lab.top/nav/Yonexus.Protocol.git

184
CONVENTIONS.md Normal file
View File

@@ -0,0 +1,184 @@
# Yonexus.Server — Development Conventions
This document defines implementation conventions for the server repository.
## 1. Naming Conventions
### Files
- use `camelCase.ts` for source files
- use descriptive module names matching responsibility
- avoid generic names like `utils.ts` unless the scope is extremely narrow and justified
Examples:
- `registry.ts`
- `pairing.ts`
- `heartbeat.ts`
- `onGatewayStart.ts`
### Types / Interfaces
- use `PascalCase`
Examples:
- `ClientRecord`
- `PairingState`
- `ServerRuntime`
### Functions
- use `camelCase`
Examples:
- `loadRegistry`
- `verifyAuthProof`
- `sendPairingNotification`
### Constants
- use `UPPER_SNAKE_CASE` for exported constants
Examples:
- `HEARTBEAT_INTERVAL_MS`
- `AUTH_WINDOW_SECONDS`
---
## 2. Module Boundaries
### `plugin/index.ts`
- wiring only
- no business logic
### `plugin/core/`
- reusable domain logic
- state transitions
- validation
- auth/pairing/heartbeat rules
### `servers/`
- network/runtime bootstrapping
- WebSocket transport lifecycle
- active connection management
### `plugin/hooks/`
- OpenClaw lifecycle integration
- should call into core/runtime modules, not reimplement logic
### `plugin/tools/`
- thin wrappers over core/runtime capabilities
- do not duplicate business rules already implemented in core
### `plugin/commands/`
- operator-facing orchestration layer
- no core logic duplication
---
## 3. Error Conventions
### Error Shape
Use structured errors rather than throwing untyped strings.
Recommended shape:
```ts
interface YonexusServerError extends Error {
code: string;
details?: Record<string, unknown>;
}
```
### Error Code Style
- uppercase snake case
- stable once published in logs/tools/docs
Examples:
- `INVALID_CONFIG`
- `IDENTIFIER_NOT_ALLOWED`
- `PAIRING_EXPIRED`
- `NONCE_COLLISION`
### Error Messages
- concise
- operator-readable
- do not leak secrets, tokens, raw proofs, or private internal state
---
## 4. Logging Conventions
### General Rule
Logs should be useful for operators and safe for production.
### Must Never Log in Plaintext
- `notifyBotToken`
- client `secret`
- raw pairing code after issuance
- raw signed proof payload
- any private key material
### Preferred Log Fields
When possible, log structured context such as:
- `identifier`
- event type
- status transition
- reason code
- timestamp
### Example Good Logs
- `pairing started for identifier=client-a`
- `auth failed for identifier=client-a reason=NONCE_COLLISION`
- `status changed identifier=client-a online->unstable`
---
## 5. Protocol Usage Conventions
- treat `protocol/` submodule as the source of truth
- do not fork protocol semantics locally without updating `Yonexus.Protocol`
- if implementation pressure suggests protocol change, change protocol repo first or in the same coordinated step
---
## 6. Persistence Conventions
- prefer explicit versionable JSON structures for initial persistence
- keep persisted schema clear and documented
- do not mix transient socket/session state into long-lived persisted records unless intentional
- keep rolling replay/rate-limit windows separate from durable trust records where practical
---
## 7. State Machine Conventions
- keep state transitions explicit
- avoid hidden/implicit mutations across unrelated modules
- centralize transitions for pairing/auth/liveness when possible
- treat `re_pair_required` as a first-class transition, not an ad-hoc side effect
---
## 8. Command / Tool Conventions
- commands and tools should surface operator-safe summaries
- raw internal state should be exposed carefully
- commands should call core/runtime services, not manipulate registry internals directly
---
## 9. Testing Conventions
- write narrow unit tests for deterministic logic first
- reserve integration tests for protocol flow and socket lifecycle
- test negative paths deliberately:
- invalid pairing code
- stale timestamp
- nonce collision
- excessive handshake attempts
- heartbeat timeout
---
## 10. Change Discipline
When implementing:
- update docs when architecture/protocol behavior changes
- avoid silent divergence from `PLAN.md`, `TASKS.md`, and `protocol/PROTOCOL.md`
- prefer fewer clear modules over many shallow wrappers

228
MANIFEST.md Normal file
View File

@@ -0,0 +1,228 @@
# Yonexus.Server — Manifest & Install Plan
This document refines the planned plugin manifest and install script behavior for `Yonexus.Server`.
## 1. Manifest File Location
Required file:
```text
plugin/openclaw.plugin.json
```
This file is the OpenClaw plugin manifest and must be treated as the single manifest entrypoint for this repository.
---
## 2. Planned Manifest Responsibilities
The manifest should define at minimum:
- plugin name
- plugin version
- plugin description
- plugin entrypoint
- default config shape
- config validation expectations
- permissions if required by runtime integration
---
## 3. Planned Manifest Skeleton
Initial planning shape:
```json
{
"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": ""
}
}
```
This is not yet final schema syntax; it is the planned manifest contract.
---
## 4. Field-Level Intent
### `name`
Must be:
- `Yonexus.Server`
### `version`
Initial planned value:
- `0.1.0`
### `description`
Should make clear this is the hub/server-side Yonexus plugin.
### `entry`
Expected build target:
- `dist/plugin/index.js`
This assumes build output mirrors the plugin source tree under `dist/`.
### `permissions`
Initial expected value:
- empty or minimal until implementation proves extra permissions are needed
Potential future needs may include:
- messaging capability for Discord DM via bot token path if exposed through runtime integration
- filesystem access depending on plugin runtime packaging expectations
### `config`
Server-specific config block.
---
## 5. Server Config Field Semantics
### `followerIdentifiers: string[]`
Allowlist of client identifiers permitted to pair/connect.
Validation expectations:
- required
- must be an array
- values should be non-empty strings
- duplicates should be rejected or normalized away
### `notifyBotToken: string`
Discord bot token used by the server to send pairing DM notifications.
Validation expectations:
- required
- non-empty string
- treated as secret/sensitive config
- never logged in plaintext
### `adminUserId: string`
Discord user id of the human administrator who receives pairing DMs.
Validation expectations:
- required
- non-empty string
- ideally numeric-string format validation if that is reliable for Discord usage
### `listenHost: string`
Local bind host for the WebSocket server.
Validation expectations:
- optional with default `0.0.0.0`
- must be valid host/bind string
### `listenPort: number`
Local bind port for the WebSocket server.
Validation expectations:
- required
- integer
- valid TCP port range
### `publicWsUrl: string`
Optional canonical external WebSocket URL used for documentation/operator reference.
Validation expectations:
- optional
- if provided, should be a valid `ws://` or `wss://` URL
---
## 6. Manifest Validation Policy
Initialization must fail when:
- `followerIdentifiers` is missing or malformed
- `notifyBotToken` is missing
- `adminUserId` is missing
- `listenPort` is missing or invalid
Initialization should warn or fail when:
- `publicWsUrl` is provided but malformed
- duplicate identifiers are present in `followerIdentifiers`
---
## 7. Install Script File Location
Required file:
```text
scripts/install.mjs
```
---
## 8. Install Script Responsibilities
The install script should support:
- `--install`
- `--uninstall`
- `--openclaw-profile-path <path>`
Default profile path:
- `~/.openclaw`
### Install Behavior
When `--install` is used:
- ensure build output exists
- copy the built plugin into the OpenClaw plugins directory
- target path should correspond to `Yonexus.Server`
Planned target form:
```text
${openclawProfilePath}/plugins/Yonexus.Server
```
### Uninstall Behavior
When `--uninstall` is used:
- remove installed plugin files from the OpenClaw plugins directory
- optionally preserve or separately handle runtime data/config unless explicit cleanup mode is added later
---
## 9. Install Script Design Notes
The install script should:
- be idempotent where reasonable
- fail clearly on missing build artifacts
- not silently delete unrelated files
- keep install/uninstall behavior explicit
Possible future options:
- `--force`
- `--clean-config`
- `--print-target`
These are not required yet.
---
## 10. Expected Relationship to Build Output
Planned build assumption:
- source lives under `plugin/`
- compiled output lands under `dist/plugin/`
- manifest entry points at compiled `dist/plugin/index.js`
The install script should copy only what is needed for runtime use.
---
## 11. Documentation Rule
When implementation begins:
- `MANIFEST.md` should stay as planning/reference
- `plugin/openclaw.plugin.json` becomes the executable truth
- if the two ever diverge, the manifest must be updated and docs corrected quickly

398
PLAN.md Normal file
View File

@@ -0,0 +1,398 @@
# Yonexus.Server — Project Plan
## 1. Goal
`Yonexus.Server` is the OpenClaw plugin that acts as the central communication hub in a Yonexus network.
This repository references `Yonexus.Protocol` as a submodule at `protocol/`.
It is responsible for:
- accepting WebSocket connections from clients
- maintaining the client registry and trust state
- handling pairing initiation and Discord DM notification
- verifying client authentication proofs
- tracking client liveness via heartbeat
- routing and dispatching application messages
- exposing a TypeScript API for server-side plugins and integrations
---
## 2. Configuration
```ts
interface YonexusServerConfig {
followerIdentifiers: string[];
notifyBotToken: string;
adminUserId: string;
listenHost?: string;
listenPort: number;
publicWsUrl?: string;
}
```
Field semantics:
- `followerIdentifiers`: allowlist of identifiers permitted to pair/connect
- `notifyBotToken`: Discord bot token for sending pairing code DM to admin
- `adminUserId`: Discord user id of the human administrator
- `listenHost`: local bind address (default: `0.0.0.0`)
- `listenPort`: local bind port (required)
- `publicWsUrl`: optional canonical external WebSocket URL advertised to clients
Validation:
- missing required fields must fail plugin initialization
- identifiers not in `followerIdentifiers` must be rejected at connection time
---
## 3. Runtime Lifecycle
### 3.1 Startup
On OpenClaw gateway startup:
1. load and validate config
2. initialize persistent client registry
3. register builtin protocol handlers
4. register application rule registry
5. start WebSocket server on configured host/port
6. start heartbeat/status sweep timer
### 3.2 Shutdown
On shutdown:
1. close all client WebSocket connections gracefully
2. persist client registry state
3. stop sweep timers
---
## 4. Client Registry
### 4.1 Data Model
```ts
interface ClientRecord {
identifier: string;
publicKey?: string;
secret?: string;
pairingStatus: "unpaired" | "pending" | "paired" | "revoked";
pairingCode?: string;
pairingExpiresAt?: number;
pairingNotifiedAt?: number;
pairingNotifyStatus?: "pending" | "sent" | "failed";
status: "online" | "offline" | "unstable";
lastHeartbeatAt?: number;
lastAuthenticatedAt?: number;
recentNonces: Array<{ nonce: string; timestamp: number }>;
recentHandshakeAttempts: number[];
createdAt: number;
updatedAt: number;
}
```
### 4.2 Persistence
Registry must survive server restarts:
- all trust-related fields must be persisted
- security rolling windows should be reset on restart or kept safely
- on-disk storage format should support future encryption-at-rest
---
## 5. Pairing Flow
### 5.1 Entry Condition
Pairing starts when a client connects and:
- its identifier is in `followerIdentifiers`
- it has no valid `secret` stored
### 5.2 Step A — Generate Pairing Code
Server generates:
- a random `pairingCode`
- `expiresAt` (UTC unix seconds)
- `ttlSeconds`
### 5.3 Step B — Discord DM to Admin
Server must use `notifyBotToken` to DM `adminUserId`.
DM body must contain:
- `identifier`
- `pairingCode`
- `expiresAt` or TTL
DM delivery must succeed before protocol continues.
### 5.4 Step C — Protocol Notification to Client
Server sends `hello_ack` with `nextAction: "pair_required"`.
Server then sends `pair_request` builtin message containing:
- `identifier`
- `expiresAt`
- `ttlSeconds`
- `adminNotification: "sent" | "failed"`
- `codeDelivery: "out_of_band"`
### 5.5 Step D — Accept Confirmation
Server accepts a `pair_confirm` builtin message from client containing the pairing code.
Validation:
- code must match stored pending code
- current time must be before `pairingExpiresAt`
### 5.6 Step E — Issue Secret
On successful confirmation:
- generate a random `secret`
- store `publicKey` and `secret`
- mark `pairingStatus` as `paired`
- send `pair_success` builtin message to client with the secret
On failure:
- send `pair_failed` builtin message
- optionally retry or leave for client to reconnect
---
## 6. Authentication
### 6.1 Entry Condition
Authentication starts when a connected client sends a `hello` with `hasSecret: true`.
### 6.2 Proof Validation
Client sends `auth_request` containing:
- `identifier`
- `nonce` (24 random characters)
- `proofTimestamp` (UTC unix seconds)
- `signature` (signed proof payload)
- optionally a new `publicKey` if rotating
Server validates:
1. identifier is allowlisted and paired
2. public key matches stored key (if not rotating)
3. signature verifies correctly
4. decrypted proof contains the correct `secret`
5. `abs(now - proofTimestamp) < 10`
6. nonce is not in recent nonce window
7. handshake attempts in last 10s ≤ 10
### 6.3 Nonce Window
Store last 10 nonces per client with their timestamps.
When a nonce is presented:
- if it matches any in the window, reject with `nonce_collision`
- add the new nonce to the window
- trim window to most recent 10 entries
### 6.4 Handshake Rate Limit
Track recent handshake attempt timestamps per client.
If >10 attempts appear in the last 10 seconds:
- reject with `rate_limited`
- trigger `re_pair_required`
- mark pairing status as `revoked`
### 6.5 Success / Failure Responses
On success:
- send `auth_success` with `status: "online"`
- record `lastAuthenticatedAt`
On failure:
- send `auth_failed` with reason
- if reason is unsafe, also send `re_pair_required`
---
## 7. Heartbeat and Liveness
### 7.1 Heartbeat Reception
Clients send `heartbeat` builtin messages every 5 minutes.
On receiving a heartbeat:
- update `lastHeartbeatAt`
- if client was `offline` or `unstable`, transition to `online`
### 7.2 Status Sweep
Server runs a periodic sweep (recommended: every 3060s).
For each registered client:
- if no heartbeat for 7 min → mark `unstable`
- if no heartbeat for 11 min → mark `offline`, close socket, send `disconnect_notice` first
### 7.3 Status Transitions
Allowed transitions:
- `online``unstable` (7 min timeout)
- `unstable``online` (heartbeat received)
- `unstable``offline` (11 min timeout)
- `offline` → (removed from active registry or marked offline permanently)
---
## 8. Messaging and Rule Dispatch
### 8.1 Message Rewrite
When server receives an application rule message from a client, before rule dispatch it rewrites:
```
${rule_identifier}::${message_content}
```
Into:
```
${rule_identifier}::${sender_identifier}::${message_content}
```
### 8.2 Rule Registry
Server maintains a registry of `(rule_identifier → processor)` pairs.
Dispatch algorithm:
1. parse first `::` segment as `rule_identifier`
2. if `rule_identifier === builtin`, route to builtin protocol handler
3. iterate registered rules in registration order
4. invoke first exact match
5. if no match, ignore or log as unhandled
### 8.3 Processor Function Signature
```ts
type RuleProcessor = (message: string) => unknown;
registerRule(rule: string, processor: RuleProcessor): void;
```
Validation:
- must reject `builtin`
- must reject duplicate rule unless explicit override mode is added later
### 8.4 API: sendMessageToClient
```ts
async function sendMessageToClient(identifier: string, message: string): Promise<void>
```
Constraints:
- identifier must be currently connected and authenticated
- message must already conform to `${rule_identifier}::${message_content}`
- throws if identifier is not online
---
## 9. WebSocket Server
### 9.1 Connection Accept
On new WebSocket connection:
1. read initial `hello` message
2. validate identifier is in allowlist
3. check if paired/authenticated or requires pairing
4. proceed accordingly
### 9.2 Connection Close
On client disconnect:
- mark client as offline immediately
- stop heartbeat tracking for that session
- keep persistent registry state intact
### 9.3 One Active Session Per Identifier
Recommended v1 policy:
- if a new authenticated connection appears for an already-authenticated identifier, terminate the old connection and accept the new one
---
## 10. Error Handling
Structured errors required for at minimum:
- `INVALID_CONFIG` — missing required config fields
- `IDENTIFIER_NOT_ALLOWED` — identifier not in allowlist
- `PAIRING_NOTIFICATION_FAILED` — Discord DM send failed
- `PAIRING_EXPIRED` — pairing code expired
- `AUTH_FAILED` — proof verification failed
- `NONCE_COLLISION` — replay detected
- `RATE_LIMITED` — unsafe handshake rate
- `RE_PAIR_REQUIRED` — trust must be reset
- `CLIENT_OFFLINE` — attempted to send to offline client
- `RULE_ALREADY_REGISTERED` — duplicate rule registration
- `RESERVED_RULE` — attempted to register `builtin`
- `MALFORMED_MESSAGE` — malformed builtin/application message
---
## 11. Implementation Phases
### Phase 0 — Skeleton
- plugin manifest and entry point
- config loading and validation
- basic OpenClaw hook registration
- minimal logging/error scaffolding
### Phase 1 — WebSocket Server
- WebSocket server startup
- connection accept / close lifecycle
- hello / hello_ack flow
- per-connection state tracking
### Phase 2 — Registry and Persistence
- in-memory client registry
- on-disk persistence (JSON or equivalent)
- restart recovery
- basic CRUD for client records
### Phase 3 — Pairing
- pairing code generation
- Discord DM via bot token
- pair_request / pair_confirm / pair_success / pair_failed
- pairing state transitions
### Phase 4 — Authentication
- auth_request verification
- signature verification
- nonce window tracking
- handshake rate limiting
- re_pair_required flow
### Phase 5 — Heartbeat and Status
- heartbeat receiver
- status sweep timer
- online / unstable / offline transitions
- disconnect_notice before socket close
### Phase 6 — Rule Dispatch and APIs
- rule registry
- message rewrite on inbound
- first-match dispatch
- `registerRule` API
- `sendMessageToClient` API
### Phase 7 — Hardening
- structured error definitions
- redacted logging for sensitive values
- integration test coverage
- failure-path coverage
---
## 12. Open Questions for Yonexus.Server
These should be resolved before or during implementation:
1. What Discord library/module will be used to send DM? (direct Discord API / discord.js / etc.)
2. Should the WebSocket server also expose an optional TLS listener?
3. Should the sweep timer interval be configurable or fixed?
4. What is the maximum supported number of concurrent connected clients?
5. Should server-side rule processors run in isolated contexts?
6. Should `sendMessageToClient` queue messages for briefly offline clients, or fail immediately?

145
README.md
View File

@@ -0,0 +1,145 @@
# 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: **core runtime MVP with Discord DM transport wired via REST API**
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
- Discord DM pairing notifications via Discord REST API (`notifyBotToken` + `adminUserId`)
Still pending before production use:
- broader lifecycle integration with real OpenClaw plugin hooks
- more operator-facing hardening / troubleshooting polish
- expanded edge-case and live-environment validation beyond the current automated suite
## Install Layout
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:
- DM delivery depends on Discord bot permissions and the target user's DM settings
- no offline message queueing
- no multi-server topology
- no management UI
- transport is covered mainly by automated tests rather than live Discord end-to-end validation
## Related Repos
- Umbrella: `../`
- Shared protocol: `../Yonexus.Protocol`
- Client plugin: `../Yonexus.Client`

249
SCAFFOLD.md Normal file
View File

@@ -0,0 +1,249 @@
# Yonexus.Server — Scaffold Plan
This document refines the repository structure into concrete planned files and responsibilities.
## 1. Planned Tree
```text
.
├── plugin/
│ ├── core/
│ │ ├── registry.ts
│ │ ├── pairing.ts
│ │ ├── auth.ts
│ │ ├── heartbeat.ts
│ │ ├── dispatch.ts
│ │ └── errors.ts
│ ├── hooks/
│ │ ├── onGatewayStart.ts
│ │ └── onGatewayStop.ts
│ ├── commands/
│ │ ├── listClients.ts
│ │ ├── showClient.ts
│ │ └── rePairClient.ts
│ ├── tools/
│ │ ├── sendMessageToClient.ts
│ │ ├── listClientStatus.ts
│ │ └── getPairingState.ts
│ ├── index.ts
│ └── openclaw.plugin.json
├── skills/
│ └── ...
├── servers/
│ ├── wsServer.ts
│ └── sessionManager.ts
├── scripts/
│ └── install.mjs
├── protocol/
│ └── ... (submodule)
├── PLAN.md
├── STRUCTURE.md
└── SCAFFOLD.md
```
This is a planning scaffold, not yet an implementation commitment down to exact filenames. Names may evolve, but responsibilities should remain stable.
---
## 2. `plugin/index.ts`
`plugin/index.ts` is wiring only.
Planned responsibilities:
- import server core modules
- import hooks
- import commands
- import tools
- initialize server runtime container
- register hooks with OpenClaw
- register commands with OpenClaw
- register tools with OpenClaw
Design rule:
- no pairing/auth/dispatch business logic should live directly in this file
---
## 3. `plugin/openclaw.plugin.json`
Planned manifest responsibilities:
- define plugin name: `Yonexus.Server`
- define entrypoint
- declare version
- define config schema / default config shape
- declare plugin metadata
- declare permissions if needed
### Planned Config Shape
```json
{
"followerIdentifiers": [],
"notifyBotToken": "",
"adminUserId": "",
"listenHost": "0.0.0.0",
"listenPort": 8787,
"publicWsUrl": ""
}
```
### Planned Validation Expectations
- `listenPort` must be required
- `followerIdentifiers` must be an array
- `notifyBotToken` must be required
- `adminUserId` must be required
---
## 4. `plugin/core/`
### `registry.ts`
Planned responsibilities:
- client registry CRUD
- active session tracking
- persistent state load/save
### `pairing.ts`
Planned responsibilities:
- pairing code generation
- pairing pending state
- Discord DM notification orchestration
- pair_confirm validation
- secret issuance
### `auth.ts`
Planned responsibilities:
- auth_request verification
- signature verification
- nonce window validation
- rate-limit checks
- re-pair trigger decisions
### `heartbeat.ts`
Planned responsibilities:
- lastHeartbeatAt updates
- status transitions
- sweep timer logic
- disconnect timeout handling
### `dispatch.ts`
Planned responsibilities:
- builtin message routing
- application rule rewriting
- rule registry
- rule dispatch
- sendMessageToClient routing
### `errors.ts`
Planned responsibilities:
- typed/structured error definitions
- standard server-side error codes
---
## 5. `plugin/hooks/`
### `onGatewayStart.ts`
Planned responsibilities:
- initialize registry/runtime
- start WebSocket server
- start heartbeat sweep
### `onGatewayStop.ts`
Planned responsibilities:
- stop heartbeat sweep
- close sockets gracefully
- persist final state if needed
---
## 6. `plugin/commands/`
These are optional in early versions, but the directory should exist.
Potential first commands:
- `listClients.ts` — list known clients and statuses
- `showClient.ts` — inspect one clients trust/liveness state
- `rePairClient.ts` — revoke trust and force next reconnect to re-pair
---
## 7. `plugin/tools/`
Potential first tools:
- `sendMessageToClient.ts`
- `listClientStatus.ts`
- `getPairingState.ts`
These should wrap core logic, not duplicate it.
---
## 8. `servers/`
### `wsServer.ts`
Planned responsibilities:
- WebSocket server startup and bind
- connection accept lifecycle
- socket-level send/receive plumbing
### `sessionManager.ts`
Planned responsibilities:
- map identifiers to active connections
- enforce one active session per identifier
- close/replace older sessions when needed
Design rule:
- service bootstrap/network transport code belongs here
- reusable business/state logic stays in `plugin/core/`
---
## 9. `skills/`
Initial version may keep this minimal.
Possible future skill topics:
- Yonexus.Server operations
- pairing recovery
- debugging auth failures
---
## 10. `scripts/install.mjs`
Planned CLI responsibilities:
- `--install`
- `--uninstall`
- `--openclaw-profile-path <path>`
Planned install behavior:
- build output copied into the OpenClaw plugin directory
- install target path determined by profile path
Planned uninstall behavior:
- remove installed plugin directory
- optionally clean server plugin config if explicitly desired later
---
## 11. Initial Bring-Up Order
Recommended first implementation order:
1. manifest + `plugin/index.ts`
2. `servers/wsServer.ts`
3. `plugin/core/registry.ts`
4. hello / hello_ack routing
5. pairing flow
6. auth flow
7. heartbeat
8. commands/tools
---
## 12. Notes
- `protocol/` is the single protocol source of truth; do not duplicate protocol docs in this repo.
- File names can change, but the wiring/core/service separation should remain.
- Commands and tools can start thin and grow later.

140
STRUCTURE.md Normal file
View File

@@ -0,0 +1,140 @@
# Yonexus.Server — Project Structure
This repository follows the standard OpenClaw plugin project layout.
## Required Layout
```text
${proj}/plugin/
${proj}/plugin/core/
${proj}/plugin/hooks/
${proj}/plugin/commands/
${proj}/plugin/tools/
${proj}/plugin/index.ts
${proj}/plugin/openclaw.plugin.json
${proj}/skills/
${proj}/servers/
${proj}/scripts/
${proj}/scripts/install.mjs
```
Where:
- `D` = directory
- `F` = file
Equivalent expanded tree:
```text
.
├── plugin/
│ ├── core/
│ ├── hooks/
│ ├── commands/
│ ├── tools/
│ ├── index.ts
│ └── openclaw.plugin.json
├── skills/
├── servers/
├── scripts/
│ └── install.mjs
├── protocol/ # git submodule -> Yonexus.Protocol
└── PLAN.md
```
## Responsibility Mapping
### `plugin/core/`
Core plugin logic lives here.
For `Yonexus.Server`, this includes:
- client registry
- pairing state machine
- secret issuance
- authentication verification
- nonce/replay protection
- heartbeat/liveness tracking
- message rewrite and dispatch
### `plugin/hooks/`
Hook handlers triggered by OpenClaw lifecycle or special events.
For `Yonexus.Server`, expected hooks include:
- gateway startup hook to initialize server runtime
- shutdown/cleanup hook if needed
### `plugin/commands/`
Slash commands exposed by the plugin.
Planned examples for `Yonexus.Server`:
- inspect client status
- force re-pair a client
- list online/offline clients
- rotate/revoke client trust
### `plugin/tools/`
Plugin-provided tools callable from the runtime.
Planned examples for `Yonexus.Server`:
- send message to client
- query client registry
- inspect pairing/auth status
### `plugin/index.ts`
Must only do wiring.
It should:
- initialize plugin entrypoint
- register hooks
- register commands
- register tools
- compose core modules
It should **not** contain business logic directly.
### `plugin/openclaw.plugin.json`
Plugin manifest and config entrypoint.
For `Yonexus.Server`, this should eventually describe:
- plugin name
- version
- entrypoint
- config schema
- required permissions if any
### `skills/`
Skills provided by the plugin.
Potential future contents:
- operational help for Yonexus.Server
- admin workflows for pairing/recovery
- troubleshooting guidance
### `servers/`
Long-running services started by the plugin.
For `Yonexus.Server`, this is where the WebSocket server implementation belongs.
Expected contents later:
- WebSocket server bootstrap
- connection/session manager
- protocol transport handling
### `scripts/install.mjs`
Install/uninstall/deployment helper script.
Expected responsibilities:
- install built plugin into OpenClaw plugin directory
- support uninstall
- support explicit OpenClaw profile path
## Design Rule
This repo should follow the `Dirigent` style of plugin organization, with one intentional difference:
- `Yonexus.Server` uses `servers/`
- it does **not** use a root-level special folder like `no-reply-api/`
## Notes
- Shared protocol specification must live in `protocol/` submodule, not duplicated locally.
- Business logic should stay out of `plugin/index.ts`.
- Services should stay out of `plugin/core/` if they are process/service bootstraps; put those in `servers/` and let `core/` contain reusable logic.

180
TASKS.md Normal file
View File

@@ -0,0 +1,180 @@
# Yonexus.Server — Implementation Tasks
This document breaks the server-side work into actionable tasks.
## Phase 0 — Repository Skeleton
- [ ] Create required directories:
- [ ] `plugin/`
- [ ] `plugin/core/`
- [ ] `plugin/hooks/`
- [ ] `plugin/commands/`
- [ ] `plugin/tools/`
- [ ] `skills/`
- [ ] `servers/`
- [ ] `scripts/`
- [ ] Create required files:
- [ ] `plugin/index.ts`
- [ ] `plugin/openclaw.plugin.json`
- [ ] `scripts/install.mjs`
- [ ] Keep `protocol/` submodule intact and documented
## Phase 1 — Manifest and Entry Wiring
- [ ] Write initial `plugin/openclaw.plugin.json`
- [ ] Define server config defaults
- [ ] Add config validation for:
- [ ] `followerIdentifiers`
- [ ] `notifyBotToken`
- [ ] `adminUserId`
- [ ] `listenHost`
- [ ] `listenPort`
- [ ] `publicWsUrl`
- [ ] Implement `plugin/index.ts` as wiring-only entrypoint
- [ ] Register hooks / commands / tools from `plugin/index.ts`
## Phase 2 — Core Runtime Foundation
- [ ] Implement structured error definitions in `plugin/core/errors.ts`
- [ ] Implement config loader / validator
- [ ] Implement runtime container/bootstrap module
- [ ] Define shared server-side types:
- [ ] client record
- [ ] active session
- [ ] pairing state
- [ ] heartbeat status
## Phase 3 — WebSocket Service Layer
- [ ] Implement `servers/wsServer.ts`
- [ ] Bind WebSocket server to configured host/port
- [ ] Handle connection open/close lifecycle
- [ ] Parse inbound text frames
- [ ] Route raw inbound frames toward protocol/application dispatch
- [ ] Implement `servers/sessionManager.ts`
- [ ] Enforce one active session per identifier
- [ ] Replace old session on new authenticated connection
## Phase 4 — Registry and Persistence
- [ ] Implement `plugin/core/registry.ts`
- [ ] Add in-memory registry for active and known clients
- [ ] Add persistence model for durable trust state
- [ ] Implement load-on-start behavior
- [ ] Implement save-on-change behavior
- [ ] Decide initial persistence format (likely JSON)
- [ ] Ensure sensitive fields are not logged in plaintext
## Phase 5 — Builtin Protocol Routing
- [ ] Implement builtin message parser
- [ ] Implement builtin envelope validation
- [ ] Route by builtin `type`
- [ ] Support at minimum:
- [ ] `hello`
- [ ] `pair_confirm`
- [ ] `auth_request`
- [ ] `heartbeat`
- [ ] Return structured error responses for malformed payloads
## Phase 6 — Pairing Flow
- [ ] Implement pairing code generation
- [ ] Implement pairing TTL / expiry
- [ ] Store pending pairing state in registry
- [ ] Implement Discord DM notification path using `notifyBotToken`
- [ ] Include `identifier` and pairing code in DM
- [ ] Return `pair_request` to client without leaking pairing code
- [ ] Validate `pair_confirm`
- [ ] Implement `pair_success`
- [ ] Implement `pair_failed`
- [ ] Handle `admin_notification_failed`
## Phase 7 — Authentication Flow
- [ ] Implement proof verification logic in `plugin/core/auth.ts`
- [ ] Verify signature against stored public key
- [ ] Verify stored secret
- [ ] Verify timestamp freshness
- [ ] Implement nonce replay protection
- [ ] Implement handshake rate limiting
- [ ] Trigger `re_pair_required` on unsafe conditions
- [ ] Send `auth_success` on success
- [ ] Send `auth_failed` on failure
## Phase 8 — Heartbeat and Status
- [ ] Implement `plugin/core/heartbeat.ts`
- [ ] Update `lastHeartbeatAt` on valid heartbeat
- [ ] Start periodic sweep timer
- [ ] Mark clients `unstable` after 7 minutes
- [ ] Mark clients `offline` after 11 minutes
- [ ] Send `disconnect_notice` before forced close
- [ ] Close socket on offline transition
- [ ] Optionally send `heartbeat_ack`
## Phase 9 — Rule Dispatch and Messaging APIs
- [ ] Implement `plugin/core/dispatch.ts`
- [ ] Implement application message parse path
- [ ] Rewrite inbound client messages to include sender identifier
- [ ] Maintain rule registry
- [ ] Reject reserved rule `builtin`
- [ ] Reject duplicate rule registrations
- [ ] Implement `sendMessageToClient(identifier, message)`
- [ ] Fail cleanly when target client is offline
## Phase 10 — Hooks
- [ ] Implement `plugin/hooks/onGatewayStart.ts`
- [ ] Implement `plugin/hooks/onGatewayStop.ts`
- [ ] Ensure startup initializes runtime exactly once
- [ ] Ensure shutdown cleans up sockets/timers
## Phase 11 — Commands and Tools
### Commands
- [ ] `listClients`
- [ ] `showClient`
- [ ] `rePairClient`
### Tools
- [ ] `sendMessageToClient`
- [ ] `listClientStatus`
- [ ] `getPairingState`
## Phase 12 — Install Script
- [ ] Implement `scripts/install.mjs`
- [ ] Support `--install`
- [ ] Support `--uninstall`
- [ ] Support `--openclaw-profile-path <path>`
- [ ] Validate build output exists before install
- [ ] Copy runtime-ready files into plugin directory
## Phase 13 — Testing
- [ ] Unit tests for config validation
- [ ] Unit tests for builtin parsing
- [ ] Unit tests for pairing logic
- [ ] Unit tests for auth verification
- [ ] Unit tests for nonce/rate-limit protection
- [ ] Integration test: first-time pairing
- [ ] Integration test: reconnect auth
- [ ] Integration test: heartbeat timeout
- [ ] Integration test: offline disconnect
## Phase 14 — Hardening
- [ ] Redact secrets from logs
- [ ] Audit error messages for sensitive leakage
- [ ] Confirm persistence behavior across restart
- [ ] Review unsafe-condition handling
- [ ] Review operator-facing command/tool ergonomics
## Nice-to-Have / Later
- [ ] TLS listener support
- [ ] Better operator diagnostics
- [ ] Queued outbound delivery strategy
- [ ] Admin approve/deny workflow beyond code relay

1318
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "yonexus-server",
"version": "0.1.0",
"private": true,
"description": "Yonexus.Server OpenClaw plugin scaffold",
"type": "module",
"main": "dist/plugin/index.js",
"openclaw": {
"extensions": ["./dist/Yonexus.Server/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
View File

0
plugin/core/.gitkeep Normal file
View File

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

@@ -0,0 +1,108 @@
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 typeof value === "number" && 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)) {
issues.push("followerIdentifiers must be an array");
}
if (new Set(followerIdentifiers).size !== followerIdentifiers.length) {
issues.push("followerIdentifiers must not contain duplicates");
}
const rawNotifyBotToken = source.notifyBotToken;
if (!isNonEmptyString(rawNotifyBotToken)) {
issues.push("notifyBotToken is required");
}
const rawAdminUserId = source.adminUserId;
if (!isNonEmptyString(rawAdminUserId)) {
issues.push("adminUserId is required");
}
const rawListenPort = source.listenPort;
if (!isValidPort(rawListenPort)) {
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);
}
const notifyBotToken = rawNotifyBotToken as string;
const adminUserId = rawAdminUserId as string;
const listenPort = rawListenPort as number;
return {
followerIdentifiers,
notifyBotToken: notifyBotToken.trim(),
adminUserId: adminUserId.trim(),
listenHost,
listenPort,
publicWsUrl
};
}

41
plugin/core/logging.ts Normal file
View 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);
}

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

@@ -0,0 +1,258 @@
/**
* Yonexus Server - Persistence Types
*
* Defines the persistent record structures for client registry and state management.
* Based on PLAN.md section 6 and 12.
*/
/**
* Client pairing status
*/
export type PairingStatus = "unpaired" | "pending" | "paired" | "revoked";
/**
* Client liveness status
*/
export type ClientLivenessStatus = "online" | "offline" | "unstable";
/**
* Pairing notification delivery status
*/
export type PairingNotifyStatus = "pending" | "sent" | "failed";
/**
* Security window entry for nonce tracking
*/
export interface NonceEntry {
/** The nonce value */
readonly nonce: string;
/** UTC unix timestamp when the nonce was used */
readonly timestamp: number;
}
/**
* Security window entry for handshake attempt tracking
*/
export interface HandshakeAttemptEntry {
/** UTC unix timestamp of the attempt */
readonly timestamp: number;
}
/**
* Persistent client record stored by Yonexus.Server
*
* This structure represents the durable trust state for a client.
* Rolling security windows (recentNonces, recentHandshakeAttempts) may be
* cleared on server restart as per v1 semantics.
*/
export interface ClientRecord {
/** Unique client identifier */
readonly identifier: string;
/** Client's public key (Ed25519 or other) - stored after pairing */
publicKey?: string;
/** Shared secret issued after successful pairing */
secret?: string;
/** Current pairing status */
pairingStatus: PairingStatus;
/** Pairing code (only valid when pairingStatus is "pending") */
pairingCode?: string;
/** Pairing expiration timestamp (UTC unix seconds) */
pairingExpiresAt?: number;
/** When the pairing notification was sent (UTC unix seconds) */
pairingNotifiedAt?: number;
/** Status of the pairing notification delivery */
pairingNotifyStatus?: PairingNotifyStatus;
/** Current liveness status (may be stale on restart) */
status: ClientLivenessStatus;
/** Last heartbeat received timestamp (UTC unix seconds) */
lastHeartbeatAt?: number;
/** Last successful authentication timestamp (UTC unix seconds) */
lastAuthenticatedAt?: number;
/** Last successful pairing timestamp (UTC unix seconds) */
pairedAt?: number;
/**
* Recent nonces used in authentication attempts.
* This is a rolling window that may be cleared on restart.
*/
recentNonces: NonceEntry[];
/**
* Recent handshake attempt timestamps.
* This is a rolling window that may be cleared on restart.
*/
recentHandshakeAttempts: number[];
/** Record creation timestamp (UTC unix seconds) */
readonly createdAt: number;
/** Record last update timestamp (UTC unix seconds) */
updatedAt: number;
}
/**
* In-memory session state (not persisted)
*
* Represents an active or pending WebSocket connection.
*/
export interface ClientSession {
/** Client identifier */
readonly identifier: string;
/** WebSocket connection instance */
readonly socket: unknown; // Will be typed as WebSocket when implementing transport
/** Public key presented during hello, before pairing completes */
publicKey?: string;
/** Whether the client is currently authenticated */
isAuthenticated: boolean;
/** Session start timestamp (UTC unix seconds) */
readonly connectedAt: number;
/** Last activity timestamp (UTC unix seconds) */
lastActivityAt: number;
}
/**
* Server registry state
*
* Contains both persistent and in-memory state for all clients.
*/
export interface ServerRegistry {
/** Persistent client records keyed by identifier */
clients: Map<string, ClientRecord>;
/** Active WebSocket sessions keyed by identifier */
sessions: Map<string, ClientSession>;
}
/**
* Serialized form of ClientRecord for JSON persistence
*/
export interface SerializedClientRecord {
identifier: string;
publicKey?: string;
secret?: string;
pairingStatus: PairingStatus;
pairingCode?: string;
pairingExpiresAt?: number;
pairingNotifiedAt?: number;
pairingNotifyStatus?: PairingNotifyStatus;
status: ClientLivenessStatus;
lastHeartbeatAt?: number;
lastAuthenticatedAt?: number;
pairedAt?: number;
createdAt: number;
updatedAt: number;
// Note: recentNonces and recentHandshakeAttempts are intentionally
// excluded from persistent serialization - they are cleared on restart
}
/**
* Server persistence file format
*/
export interface ServerPersistenceData {
/** Format version for migration support */
version: number;
/** Server-side client records */
clients: SerializedClientRecord[];
/** Persistence timestamp (UTC unix seconds) */
persistedAt: number;
}
/**
* Create a new empty client record
*/
export function createClientRecord(identifier: string): ClientRecord {
const now = Math.floor(Date.now() / 1000);
return {
identifier,
pairingStatus: "unpaired",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now,
updatedAt: now
};
}
/**
* Convert a ClientRecord to its serialized form (for persistence)
*/
export function serializeClientRecord(record: ClientRecord): SerializedClientRecord {
return {
identifier: record.identifier,
publicKey: record.publicKey,
secret: record.secret,
pairingStatus: record.pairingStatus,
pairingCode: record.pairingCode,
pairingExpiresAt: record.pairingExpiresAt,
pairingNotifiedAt: record.pairingNotifiedAt,
pairingNotifyStatus: record.pairingNotifyStatus,
status: record.status,
lastHeartbeatAt: record.lastHeartbeatAt,
lastAuthenticatedAt: record.lastAuthenticatedAt,
pairedAt: record.pairedAt,
createdAt: record.createdAt,
updatedAt: record.updatedAt
};
}
/**
* Deserialize a client record and initialize rolling windows
*/
export function deserializeClientRecord(
serialized: SerializedClientRecord
): ClientRecord {
return {
...serialized,
recentNonces: [], // Rolling windows cleared on restart
recentHandshakeAttempts: []
};
}
/**
* Check if a client record is in a pairable state
*/
export function isPairable(record: ClientRecord): boolean {
return record.pairingStatus === "unpaired" || record.pairingStatus === "revoked";
}
/**
* Check if a client record has a pending pairing that may have expired
*/
export function hasPendingPairing(record: ClientRecord): boolean {
return record.pairingStatus === "pending" && record.pairingCode !== undefined;
}
/**
* Check if a pending pairing has expired
*/
export function isPairingExpired(record: ClientRecord, now: number = Date.now() / 1000): boolean {
if (!hasPendingPairing(record) || record.pairingExpiresAt === undefined) {
return false;
}
return now > record.pairingExpiresAt;
}
/**
* Check if a client is ready for authentication (has secret and is paired)
*/
export function canAuthenticate(record: ClientRecord): boolean {
return record.pairingStatus === "paired" && record.secret !== undefined && record.publicKey !== undefined;
}

89
plugin/core/rules.ts Normal file
View 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();
}

956
plugin/core/runtime.ts Normal file
View File

@@ -0,0 +1,956 @@
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.Protocol/src/crypto.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";
import type { ServerRuleRegistry } from "./rules.js";
export interface YonexusServerRuntimeOptions {
config: YonexusServerConfig;
store: YonexusServerStore;
transport: ServerTransport;
notificationService?: DiscordNotificationService;
ruleRegistry?: ServerRuleRegistry;
onClientAuthenticated?: (identifier: string) => void;
now?: () => number;
sweepIntervalMs?: number;
}
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 =
options.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;
}
const promoted = this.options.transport.promoteToAuthenticated(identifier, connection.ws);
if (promoted) {
this.options.onClientAuthenticated?.(identifier);
}
this.options.transport.sendToConnection(
{ ...connection, identifier },
encodeBuiltin(
buildAuthSuccess(
{
identifier,
authenticatedAt: now,
status: "online"
},
{ requestId: envelope.requestId, timestamp: now }
)
)
);
await this.persist();
}
private determineNextAction(record: ClientRecord): "pair_required" | "auth_required" | "waiting_pair_confirm" {
if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) {
return "waiting_pair_confirm";
}
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);
}
// Persist immediately so the pairing code is readable from disk (e.g. via CLI)
if (!reusePending) {
await this.persist();
}
this.options.transport.sendToConnection(
connection,
encodeBuiltin(
buildPairRequest(
{
identifier: record.identifier,
expiresAt: request.expiresAt,
ttlSeconds: request.ttlSeconds,
adminNotification: notified ? "sent" : "failed",
codeDelivery: "out_of_band"
},
{ requestId, timestamp: this.now() }
)
)
);
// Pairing remains pending regardless of notification status.
// The admin can retrieve the pairing code via the server CLI command.
}
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}`;
session.lastActivityAt = this.now();
this.options.ruleRegistry?.dispatch(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
View 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"
);
}

295
plugin/core/transport.ts Normal file
View File

@@ -0,0 +1,295 @@
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);
}
// Also close any OTHER temp connections that claimed the same identifier.
// This handles the case where a second hello came in with the same identifier
// while the first was still in the temp/pairing phase.
for (const [otherWs, otherTemp] of this.tempConnections.entries()) {
if (otherWs !== ws && otherTemp.assignedIdentifier === identifier) {
otherWs.close(1008, "Connection replaced by new authenticated session");
this.tempConnections.delete(otherWs);
}
}
// Remove from temp connections
this.tempConnections.delete(ws);
// 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");
// If this ws is still in temp state, use tempConn directly.
// Never fall through to _connections — it may hold a stale entry for the
// same identifier from a previously-authenticated session that hasn't
// finished closing yet, which would cause promoteToAuthenticated to receive
// the wrong WebSocket and silently fail.
if (this.tempConnections.has(ws)) {
this.options.onMessage(tempConn, message);
return;
}
// ws has been promoted — find it in authenticated connections
let connection: ClientConnection = tempConn;
for (const [, conn] of this._connections) {
if (conn.ws === ws) {
connection = conn;
break;
}
}
this.options.onMessage(connection, message);
});
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
View 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
View File

258
plugin/index.ts Normal file
View File

@@ -0,0 +1,258 @@
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";
import path from "node:path";
import fs from "node:fs";
import { validateYonexusServerConfig } from "./core/config.js";
import { createYonexusServerStore } from "./core/store.js";
import { createServerTransport, type ServerTransport } from "./core/transport.js";
import { createYonexusServerRuntime } from "./core/runtime.js";
import { createServerRuleRegistry, YonexusServerRuleRegistry } from "./core/rules.js";
import { encodeRuleMessage } from "../../Yonexus.Protocol/src/index.js";
import type { ServerPersistenceData } from "./core/persistence.js";
const _G = globalThis as Record<string, unknown>;
const _STARTED_KEY = "_yonexusServerStarted";
const _TRANSPORT_KEY = "_yonexusServerTransport";
const _REGISTRY_KEY = "_yonexusServerRegistry";
const _CALLBACKS_KEY = "_yonexusServerOnAuthCallbacks";
export interface YonexusServerPluginManifest {
readonly name: "Yonexus.Server";
readonly version: string;
readonly description: string;
}
const manifest: YonexusServerPluginManifest = {
name: "Yonexus.Server",
version: "0.1.0",
description: "Yonexus central hub plugin for cross-instance OpenClaw communication"
};
export function createYonexusServerPlugin(api: {
rootDir: string;
pluginConfig: unknown;
registrationMode?: string; // "full" (gateway) | "cli-metadata" | "setup-only" | "setup-runtime"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerCli?: (registrar: (ctx: { program: any }) => void, opts?: { commands?: string[] }) => void;
}): void {
const stateFilePath = path.join(api.rootDir, "state.json");
// Register CLI regardless of whether the gateway is already running.
// The CLI process is a separate invocation that reads from the persisted state file.
api.registerCli?.(({ program }) => {
const group = program
.command("yonexus-server")
.description("Yonexus.Server management");
group
.command("pair-code <identifier>")
.description("Show the pending pairing code for a device awaiting confirmation")
.action((identifier: string) => {
let raw: ServerPersistenceData;
try {
raw = JSON.parse(fs.readFileSync(stateFilePath, "utf8")) as ServerPersistenceData;
} catch {
console.error("Error: could not read server state. Is the gateway running?");
process.exit(1);
}
const client = raw.clients?.find((c) => c.identifier === identifier);
if (!client) {
console.error(`Error: identifier "${identifier}" not found in server registry.`);
process.exit(1);
}
if (client.pairingStatus !== "pending" || !client.pairingCode) {
const status = client.pairingStatus;
console.error(`Error: no pending pairing for "${identifier}" (status: ${status}).`);
process.exit(1);
}
if (client.pairingExpiresAt && Math.floor(Date.now() / 1000) > client.pairingExpiresAt) {
console.error(`Error: pairing for "${identifier}" has expired.`);
process.exit(1);
}
const expiresIn = client.pairingExpiresAt
? Math.max(0, client.pairingExpiresAt - Math.floor(Date.now() / 1000))
: 0;
const mm = String(Math.floor(expiresIn / 60)).padStart(2, "0");
const ss = String(expiresIn % 60).padStart(2, "0");
console.log(`Identifier : ${client.identifier}`);
console.log(`Pairing code : ${client.pairingCode}`);
console.log(`Expires in : ${mm}m ${ss}s`);
});
group
.command("list-pending")
.description("List all identifiers with a pending pairing code")
.action(() => {
let raw: ServerPersistenceData;
try {
raw = JSON.parse(fs.readFileSync(stateFilePath, "utf8")) as ServerPersistenceData;
} catch {
console.error("Error: could not read server state. Is the gateway running?");
process.exit(1);
}
const now = Math.floor(Date.now() / 1000);
const pending = (raw.clients ?? []).filter(
(c) => c.pairingStatus === "pending" && c.pairingCode && (!c.pairingExpiresAt || now <= c.pairingExpiresAt)
);
if (pending.length === 0) {
console.log("No pending pairings.");
return;
}
for (const c of pending) {
const expiresIn = c.pairingExpiresAt ? Math.max(0, c.pairingExpiresAt - now) : 0;
const mm = String(Math.floor(expiresIn / 60)).padStart(2, "0");
const ss = String(expiresIn % 60).padStart(2, "0");
console.log(` ${c.identifier} (expires in ${mm}m ${ss}s)`);
}
});
}, { commands: ["yonexus-server"] });
// 1. Ensure shared state survives hot-reload — only initialise when absent
if (!(_G[_REGISTRY_KEY] instanceof YonexusServerRuleRegistry)) {
_G[_REGISTRY_KEY] = createServerRuleRegistry();
}
if (!Array.isArray(_G[_CALLBACKS_KEY])) {
_G[_CALLBACKS_KEY] = [];
}
const ruleRegistry = _G[_REGISTRY_KEY] as YonexusServerRuleRegistry;
const onClientAuthenticatedCallbacks = _G[_CALLBACKS_KEY] as Array<(identifier: string) => void>;
// 2. Refresh the cross-plugin API object every call so that sendRule closure
// always reads the live transport from globalThis.
_G["__yonexusServer"] = {
ruleRegistry,
sendRule: (identifier: string, ruleId: string, content: string): boolean =>
(_G[_TRANSPORT_KEY] as ServerTransport | undefined)?.send(identifier, encodeRuleMessage(ruleId, content)) ?? false,
onClientAuthenticated: onClientAuthenticatedCallbacks
};
// 3. Start the runtime only once — the globalThis flag survives hot-reload
if (_G[_STARTED_KEY]) return;
_G[_STARTED_KEY] = true;
const config = validateYonexusServerConfig(api.pluginConfig);
const store = createYonexusServerStore(stateFilePath);
// runtimeRef is local; transport is stored in globalThis so sendRule closures stay valid
let runtimeRef: ReturnType<typeof createYonexusServerRuntime> | null = null;
const transport = createServerTransport({
config,
onMessage: (conn, msg) => {
runtimeRef?.handleMessage(conn, msg).catch((err: unknown) => {
console.error("[yonexus-server] message handler error:", err);
});
},
onDisconnect: (identifier) => {
if (identifier && runtimeRef) {
runtimeRef.handleDisconnect(identifier);
}
}
});
_G[_TRANSPORT_KEY] = transport;
const runtime = createYonexusServerRuntime({
config,
store,
transport,
ruleRegistry,
onClientAuthenticated: (identifier) => {
for (const cb of onClientAuthenticatedCallbacks) cb(identifier);
}
});
runtimeRef = runtime;
const shutdown = (): void => {
runtime.stop().catch((err: unknown) => {
console.error("[yonexus-server] shutdown error:", err);
});
};
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
runtime.start().catch((err: unknown) => {
// EADDRINUSE means the gateway is already running (e.g. this is a CLI invocation).
// Any other error is a real problem worth logging.
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code !== "EADDRINUSE") {
console.error("[yonexus-server] failed to start:", err);
}
});
}
export default createYonexusServerPlugin;
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 };

View File

@@ -0,0 +1,191 @@
/**
* 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, safeErrorMessage } 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;
}
export interface DiscordApiResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
export type DiscordFetch = (
input: string,
init?: {
method?: string;
headers?: Record<string, string>;
body?: string;
}
) => Promise<DiscordApiResponse>;
interface CreateDmChannelResponse {
id?: string;
}
interface SendDiscordDirectMessageOptions {
config: DiscordNotificationConfig;
message: string;
fetcher: DiscordFetch;
}
const DISCORD_API_BASE_URL = "https://discord.com/api/v10";
/**
* Create a Discord notification service backed by Discord's REST API.
*
* Flow:
* 1. Create or fetch a DM channel for the configured admin user
* 2. Post the formatted pairing message into that DM channel
*/
export function createDiscordNotificationService(
config: DiscordNotificationConfig,
options: { fetcher?: DiscordFetch } = {}
): DiscordNotificationService {
const fetcher = options.fetcher ?? getDefaultFetch();
return {
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
if (!config.botToken.trim() || !config.adminUserId.trim()) {
console.error("[Yonexus.Server] Discord DM notification misconfigured", {
hasBotToken: Boolean(config.botToken.trim()),
hasAdminUserId: Boolean(config.adminUserId.trim())
});
return false;
}
try {
await sendDiscordDirectMessage({
config,
message: formatPairingMessage(request),
fetcher
});
console.log("[Yonexus.Server] Pairing notification sent via Discord DM", {
identifier: request.identifier,
pairingCode: redactPairingCode(request.pairingCode),
expiresAt: request.expiresAt,
ttlSeconds: request.ttlSeconds,
adminUserId: config.adminUserId
});
return true;
} catch (error) {
console.error("[Yonexus.Server] Failed to send Discord DM pairing notification", {
identifier: request.identifier,
pairingCode: redactPairingCode(request.pairingCode),
adminUserId: config.adminUserId,
error: safeErrorMessage(error)
});
return false;
}
}
};
}
async function sendDiscordDirectMessage(
options: SendDiscordDirectMessageOptions
): Promise<void> {
const { config, message, fetcher } = options;
const headers = {
Authorization: `Bot ${config.botToken}`,
"Content-Type": "application/json"
};
const dmResponse = await fetcher(`${DISCORD_API_BASE_URL}/users/@me/channels`, {
method: "POST",
headers,
body: JSON.stringify({ recipient_id: config.adminUserId })
});
if (!dmResponse.ok) {
throw new Error(`Discord DM channel creation failed with status ${dmResponse.status}`);
}
const dmPayload = (await dmResponse.json()) as CreateDmChannelResponse;
const channelId = dmPayload.id?.trim();
if (!channelId) {
throw new Error("Discord DM channel creation did not return a channel id");
}
const messageResponse = await fetcher(`${DISCORD_API_BASE_URL}/channels/${channelId}/messages`, {
method: "POST",
headers,
body: JSON.stringify({ content: message })
});
if (!messageResponse.ok) {
throw new Error(`Discord DM send failed with status ${messageResponse.status}`);
}
await messageResponse.json();
}
function getDefaultFetch(): DiscordFetch {
if (typeof fetch !== "function") {
throw new Error("Global fetch is not available in this runtime");
}
return (input, init) =>
fetch(input, {
method: init?.method,
headers: init?.headers,
body: init?.body
}) as Promise<DiscordApiResponse>;
}
/**
* 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;
}
};
}

View File

@@ -0,0 +1,24 @@
{
"id": "yonexus-server",
"name": "Yonexus.Server",
"version": "0.1.0",
"description": "Yonexus central hub plugin for cross-instance OpenClaw communication",
"entry": "./dist/Yonexus.Server/plugin/index.js",
"permissions": [],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"followerIdentifiers": {
"type": "array",
"items": { "type": "string" }
},
"notifyBotToken": { "type": "string" },
"adminUserId": { "type": "string" },
"listenHost": { "type": "string" },
"listenPort": { "type": "number" },
"publicWsUrl": { "type": "string" }
},
"required": ["notifyBotToken", "adminUserId", "listenPort"]
}
}

190
plugin/services/pairing.ts Normal file
View 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
View File

21
plugin/types/ws.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module "ws" {
export type RawData = Buffer | ArrayBuffer | Buffer[] | string;
export class WebSocket {
static readonly OPEN: number;
readonly readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
on(event: "message", listener: (data: RawData) => void): this;
on(event: "close", listener: (code: number, reason: Buffer) => void): this;
on(event: "error", listener: (error: Error) => void): this;
}
export class WebSocketServer {
constructor(options: { host?: string; port: number });
on(event: "error", listener: (error: Error) => void): this;
on(event: "listening", listener: () => void): this;
on(event: "connection", listener: (ws: WebSocket, req: import("http").IncomingMessage) => void): this;
close(callback?: () => void): void;
}
}

1
protocol Submodule

Submodule protocol added at ccdf167daf

79
scripts/install.mjs Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execSync } from "node:child_process";
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 pluginId = "yonexus-server";
const sourceDist = path.join(repoRoot, "dist");
const targetDir = path.join(profilePath, "plugins", pluginId);
function oc(cmd) {
try {
return execSync(`openclaw ${cmd}`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
} catch (e) {
console.error(` ⚠ openclaw ${cmd}: ${e.stderr?.trim() || e.message}`);
return null;
}
}
function ensureInArray(configKey, value) {
const raw = oc(`config get ${configKey}`);
if (!raw) return;
const arr = JSON.parse(raw);
if (!arr.includes(value)) {
arr.push(value);
oc(`config set ${configKey} '${JSON.stringify(arr)}' --json`);
console.log(`${configKey} includes ${value}`);
} else {
console.log(`${configKey} already includes ${value}`);
}
}
function removeFromArray(configKey, value) {
const raw = oc(`config get ${configKey}`);
if (!raw) return;
const arr = JSON.parse(raw);
const filtered = arr.filter(v => v !== value);
if (filtered.length !== arr.length) {
oc(`config set ${configKey} '${JSON.stringify(filtered)}' --json`);
console.log(` ✓ Removed ${value} from ${configKey}`);
}
}
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"));
fs.copyFileSync(path.join(repoRoot, "package.json"), path.join(targetDir, "package.json"));
console.log(` ✓ Plugin files → ${targetDir}`);
ensureInArray("plugins.load.paths", targetDir);
ensureInArray("plugins.allow", pluginId);
console.log(`Installed ${pluginId} to ${targetDir}`);
process.exit(0);
}
// uninstall
fs.rmSync(targetDir, { recursive: true, force: true });
removeFromArray("plugins.load.paths", targetDir);
removeFromArray("plugins.allow", pluginId);
console.log(`Removed ${pluginId} from ${targetDir}`);

0
servers/.gitkeep Normal file
View File

0
skills/.gitkeep Normal file
View File

705
tests/auth-failures.test.ts Normal file
View File

@@ -0,0 +1,705 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
buildAuthRequest,
decodeBuiltin,
encodeBuiltin,
createAuthRequestSigningInput
} from "../../Yonexus.Protocol/src/index.js";
import { createYonexusServerRuntime } from "../plugin/core/runtime.js";
import type { ClientRecord } from "../plugin/core/persistence.js";
import type { YonexusServerStore } from "../plugin/core/store.js";
import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js";
import { generateKeyPair, signMessage } from "../../Yonexus.Protocol/src/crypto.js";
function createMockSocket() {
return { close: vi.fn() } as unknown as ClientConnection["ws"];
}
function createConnection(identifier: string | null = null): ClientConnection {
return {
identifier,
ws: createMockSocket(),
connectedAt: 1_710_000_000,
isAuthenticated: false
};
}
function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore {
const persisted = new Map(initialClients.map((record) => [record.identifier, record]));
return {
filePath: "/tmp/yonexus-server-auth-failures.json",
load: vi.fn(async () => ({
version: 1,
persistedAt: 1_710_000_000,
clients: new Map(persisted)
})),
save: vi.fn(async (clients: Iterable<ClientRecord>) => {
persisted.clear();
for (const client of clients) {
persisted.set(client.identifier, client);
}
})
};
}
function createMockTransport() {
const sent: Array<{ connection: ClientConnection; message: string }> = [];
const transport: ServerTransport = {
isRunning: false,
connections: new Map(),
start: vi.fn(),
stop: vi.fn(),
send: vi.fn((identifier: string, message: string) => {
sent.push({ connection: { identifier } as ClientConnection, message });
return true;
}),
sendToConnection: vi.fn((connection: ClientConnection, message: string) => {
sent.push({ connection, message });
return true;
}),
broadcast: vi.fn(),
closeConnection: vi.fn(),
promoteToAuthenticated: vi.fn(),
removeTempConnection: vi.fn(),
assignIdentifierToTemp: vi.fn()
};
return { transport, sent };
}
async function buildSignedAuthRequest(options: {
identifier: string;
secret: string;
privateKey: string;
publicKey: string;
nonce: string;
proofTimestamp: number;
requestId?: string;
signatureOverride?: string;
publicKeyOverride?: string;
}) {
const signature =
options.signatureOverride ??
(await signMessage(
options.privateKey,
createAuthRequestSigningInput({
secret: options.secret,
nonce: options.nonce,
proofTimestamp: options.proofTimestamp
})
));
return encodeBuiltin(
buildAuthRequest(
{
identifier: options.identifier,
nonce: options.nonce,
proofTimestamp: options.proofTimestamp,
signature,
publicKey: options.publicKeyOverride ?? options.publicKey
},
{ requestId: options.requestId, timestamp: options.proofTimestamp }
)
);
}
describe("YNX-1105c: Auth Failure Paths", () => {
let now = 1_710_000_000;
beforeEach(() => {
now = 1_710_000_000;
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("AF-01: unknown identifier returns auth_failed(unknown_identifier)", async () => {
const keyPair = await generateKeyPair();
const store = createMockStore([]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("rogue-client");
await runtime.handleMessage(
connection,
await buildSignedAuthRequest({
identifier: "rogue-client",
secret: "shared-secret",
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
nonce: "NONCE1234567890123456789",
proofTimestamp: now,
requestId: "req-auth-unknown"
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("auth_failed");
expect(lastMessage.payload).toMatchObject({
identifier: "rogue-client",
reason: "unknown_identifier"
});
});
it("AF-02: auth before pairing returns auth_failed(not_paired)", async () => {
const keyPair = await generateKeyPair();
const store = createMockStore([
{
identifier: "client-a",
pairingStatus: "unpaired",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now - 10,
updatedAt: now - 10
}
]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
await runtime.handleMessage(
connection,
await buildSignedAuthRequest({
identifier: "client-a",
secret: "shared-secret",
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
nonce: "NONCE1234567890123456789",
proofTimestamp: now,
requestId: "req-auth-not-paired"
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("auth_failed");
expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "not_paired" });
});
it("AF-03: invalid signature returns auth_failed(invalid_signature)", async () => {
const keyPair = await generateKeyPair();
const wrongKeyPair = await generateKeyPair();
const store = createMockStore([
{
identifier: "client-a",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "shared-secret",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now - 10,
updatedAt: now - 10
}
]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
await runtime.handleMessage(
connection,
await buildSignedAuthRequest({
identifier: "client-a",
secret: "shared-secret",
privateKey: wrongKeyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
nonce: "NONCE1234567890123456789",
proofTimestamp: now,
requestId: "req-auth-invalid-signature"
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("auth_failed");
expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "invalid_signature" });
});
it("AF-05: stale timestamp returns auth_failed(stale_timestamp)", async () => {
const keyPair = await generateKeyPair();
const store = createMockStore([
{
identifier: "client-a",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "shared-secret",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now - 20,
updatedAt: now - 20
}
]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
await runtime.handleMessage(
connection,
await buildSignedAuthRequest({
identifier: "client-a",
secret: "shared-secret",
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
nonce: "NONCE1234567890123456789",
proofTimestamp: now - 11,
requestId: "req-auth-stale"
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("auth_failed");
expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "stale_timestamp" });
});
it("AF-06: future timestamp returns auth_failed(future_timestamp)", async () => {
const keyPair = await generateKeyPair();
const store = createMockStore([
{
identifier: "client-a",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "shared-secret",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now - 20,
updatedAt: now - 20
}
]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
await runtime.handleMessage(
connection,
await buildSignedAuthRequest({
identifier: "client-a",
secret: "shared-secret",
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
nonce: "NONCE1234567890123456789",
proofTimestamp: now + 11,
requestId: "req-auth-future"
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("auth_failed");
expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "future_timestamp" });
});
it("AF-07: nonce collision triggers re_pair_required", async () => {
const keyPair = await generateKeyPair();
const store = createMockStore([
{
identifier: "client-a",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "shared-secret",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now - 10,
updatedAt: now - 10
}
]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
const nonce = "NONCE1234567890123456789";
await runtime.handleMessage(
connection,
await buildSignedAuthRequest({
identifier: "client-a",
secret: "shared-secret",
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
nonce,
proofTimestamp: now,
requestId: "req-auth-1"
})
);
now += 1;
await runtime.handleMessage(
connection,
await buildSignedAuthRequest({
identifier: "client-a",
secret: "shared-secret",
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
nonce,
proofTimestamp: now,
requestId: "req-auth-2"
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("re_pair_required");
expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "nonce_collision" });
const record = runtime.state.registry.clients.get("client-a");
expect(record?.secret).toBeUndefined();
expect(record?.pairingStatus).toBe("revoked");
});
it("AF-08: rate limit triggers re_pair_required", async () => {
const keyPair = await generateKeyPair();
const store = createMockStore([
{
identifier: "client-a",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "shared-secret",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: Array.from({ length: 10 }, () => now - 1),
createdAt: now - 10,
updatedAt: now - 10
}
]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
await runtime.handleMessage(
connection,
await buildSignedAuthRequest({
identifier: "client-a",
secret: "shared-secret",
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
nonce: "NONCE9876543210987654321",
proofTimestamp: now,
requestId: "req-auth-rate-limit"
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("re_pair_required");
expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "rate_limited" });
const record = runtime.state.registry.clients.get("client-a");
expect(record?.secret).toBeUndefined();
expect(record?.pairingStatus).toBe("revoked");
});
it("AF-09: wrong public key returns auth_failed(invalid_signature)", async () => {
const keyPair = await generateKeyPair();
const rotatedKeyPair = await generateKeyPair();
const store = createMockStore([
{
identifier: "client-a",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "shared-secret",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now - 10,
updatedAt: now - 10
}
]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
await runtime.handleMessage(
connection,
await buildSignedAuthRequest({
identifier: "client-a",
secret: "shared-secret",
privateKey: rotatedKeyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
publicKeyOverride: rotatedKeyPair.publicKey.trim(),
nonce: "NONCE1234567890123456789",
proofTimestamp: now,
requestId: "req-auth-wrong-public-key"
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("auth_failed");
expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "invalid_signature" });
});
it("AF-10: malformed auth_request payload returns protocol error", async () => {
const store = createMockStore([]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
await runtime.handleMessage(
connection,
encodeBuiltin({
type: "auth_request",
requestId: "req-auth-malformed",
timestamp: now
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("error");
expect(lastMessage.payload).toMatchObject({
code: "MALFORMED_MESSAGE",
message: "auth_request payload is required"
});
});
it("AF-11: tampered signature returns auth_failed(invalid_signature)", async () => {
const keyPair = await generateKeyPair();
const store = createMockStore([
{
identifier: "client-a",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "shared-secret",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now - 10,
updatedAt: now - 10
}
]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
const validMessage = decodeBuiltin(
await buildSignedAuthRequest({
identifier: "client-a",
secret: "shared-secret",
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey.trim(),
nonce: "NONCE1234567890123456789",
proofTimestamp: now,
requestId: "req-auth-tampered"
})
);
await runtime.handleMessage(
connection,
encodeBuiltin({
...validMessage,
payload: {
...validMessage.payload,
signature: `A${String(validMessage.payload?.signature).slice(1)}`
}
})
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("auth_failed");
expect(lastMessage.payload).toMatchObject({ identifier: "client-a", reason: "invalid_signature" });
});
});

View File

@@ -0,0 +1,297 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
buildHeartbeat,
buildHello,
decodeBuiltin,
encodeBuiltin,
YONEXUS_PROTOCOL_VERSION
} from "../../Yonexus.Protocol/src/index.js";
import { createYonexusServerRuntime } from "../plugin/core/runtime.js";
import { createClientRecord, type ClientRecord } from "../plugin/core/persistence.js";
import type { YonexusServerStore } from "../plugin/core/store.js";
import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js";
function createMockSocket() {
return { close: vi.fn() } as unknown as ClientConnection["ws"];
}
function createConnection(identifier: string | null = null): ClientConnection {
return {
identifier,
ws: createMockSocket(),
connectedAt: 1_710_000_000,
isAuthenticated: false
};
}
function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore {
const persisted = new Map(initialClients.map((record) => [record.identifier, record]));
return {
filePath: "/tmp/yonexus-server-connection-heartbeat-failures.json",
load: vi.fn(async () => ({
version: 1,
persistedAt: 1_710_000_000,
clients: new Map(persisted)
})),
save: vi.fn(async (clients: Iterable<ClientRecord>) => {
persisted.clear();
for (const client of clients) {
persisted.set(client.identifier, client);
}
})
};
}
function createMockTransport() {
const sent: Array<{ connection: ClientConnection; message: string }> = [];
const tempAssignments = new Map<ClientConnection["ws"], string>();
const connections = new Map<string, ClientConnection>();
const transport: ServerTransport = {
isRunning: false,
connections,
start: vi.fn(),
stop: vi.fn(),
send: vi.fn(),
sendToConnection: vi.fn((connection: ClientConnection, message: string) => {
sent.push({ connection, message });
return true;
}),
broadcast: vi.fn(),
closeConnection: vi.fn(),
promoteToAuthenticated: vi.fn((identifier: string, ws: ClientConnection["ws"]) => {
if (!tempAssignments.has(ws)) {
return false;
}
const existing = connections.get(identifier);
if (existing) {
existing.ws.close(1008, "Connection replaced by new authenticated session");
}
connections.set(identifier, {
identifier,
ws,
connectedAt: 1_710_000_000,
isAuthenticated: true
});
tempAssignments.delete(ws);
return true;
}),
removeTempConnection: vi.fn((ws: ClientConnection["ws"]) => {
tempAssignments.delete(ws);
}),
assignIdentifierToTemp: vi.fn((ws: ClientConnection["ws"], identifier: string) => {
tempAssignments.set(ws, identifier);
})
};
return { transport, sent };
}
describe("YNX-1105d: Connection & Heartbeat Failure Paths", () => {
let now = 1_710_000_000;
beforeEach(() => {
now = 1_710_000_000;
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("CF-06: unauthenticated rule message closes connection", async () => {
const record = createClientRecord("client-a");
const store = createMockStore([record]);
const { transport } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: undefined
});
await runtime.handleMessage(connection, "chat::hello");
expect(connection.ws.close).toHaveBeenCalledWith(1008, "Not authenticated");
});
it("HF-03: heartbeat before auth returns error", async () => {
const record = createClientRecord("client-a");
const store = createMockStore([record]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: undefined
});
await runtime.handleMessage(
connection,
encodeBuiltin(
buildHeartbeat(
{ identifier: "client-a", status: "alive" },
{ requestId: "req-hb-early", timestamp: now }
)
)
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("error");
expect(lastMessage.payload).toMatchObject({
code: "AUTH_FAILED"
});
});
it("HF-04: heartbeat without session returns error", async () => {
const record = createClientRecord("client-a");
const store = createMockStore([record]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection("client-a");
await runtime.handleMessage(
connection,
encodeBuiltin(
buildHeartbeat(
{ identifier: "client-a", status: "alive" },
{ requestId: "req-hb-unauth", timestamp: now }
)
)
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("error");
expect(lastMessage.payload).toMatchObject({
code: "AUTH_FAILED"
});
});
it("CF-04: protocol version mismatch returns error and closes the connection", async () => {
const record = createClientRecord("client-a");
const store = createMockStore([record]);
const { transport, sent } = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport,
now: () => now
});
await runtime.start();
const connection = createConnection();
await runtime.handleMessage(
connection,
encodeBuiltin(
buildHello(
{
identifier: "client-a",
hasSecret: false,
hasKeyPair: false,
protocolVersion: `${YONEXUS_PROTOCOL_VERSION}-unsupported`
},
{ requestId: "req-hello-version", timestamp: now }
)
)
);
const lastMessage = decodeBuiltin(sent.at(-1)!.message);
expect(lastMessage.type).toBe("error");
expect(lastMessage.payload).toMatchObject({
code: "UNSUPPORTED_PROTOCOL_VERSION"
});
expect(connection.ws.close).toHaveBeenCalledWith(1002, "Unsupported protocol version");
});
it("CF-03: promoting a new authenticated connection replaces the old one", async () => {
const previousSocket = createMockSocket();
const replacementSocket = createMockSocket();
const previousConnection = {
identifier: "client-a",
ws: previousSocket,
connectedAt: now - 5,
isAuthenticated: true
} satisfies ClientConnection;
const { transport } = createMockTransport();
transport.connections.set("client-a", previousConnection);
transport.assignIdentifierToTemp(replacementSocket, "client-a");
const promoted = transport.promoteToAuthenticated("client-a", replacementSocket);
expect(promoted).toBe(true);
expect(previousSocket.close).toHaveBeenCalledWith(
1008,
"Connection replaced by new authenticated session"
);
const activeConnection = transport.connections.get("client-a");
expect(activeConnection?.ws).toBe(replacementSocket);
expect(activeConnection?.isAuthenticated).toBe(true);
});
});

107
tests/notifications.test.ts Normal file
View File

@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createDiscordNotificationService,
formatPairingMessage,
type DiscordFetch
} from "../plugin/notifications/discord.js";
const request = {
identifier: "client-a",
pairingCode: "PAIR-1234-CODE",
expiresAt: 1_710_000_300,
ttlSeconds: 300,
createdAt: 1_710_000_000
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("Discord notification service", () => {
it("formats pairing requests as a DM-friendly message", () => {
const message = formatPairingMessage(request);
expect(message).toContain("Yonexus Pairing Request");
expect(message).toContain("`client-a`");
expect(message).toContain("`PAIR-1234-CODE`");
expect(message).toContain("TTL:** 300 seconds");
});
it("creates a DM channel and posts the pairing message", async () => {
const fetcher = vi
.fn<DiscordFetch>()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: "dm-channel-1" })
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: "message-1" })
});
const service = createDiscordNotificationService(
{
botToken: "discord-bot-token",
adminUserId: "123456789012345678"
},
{ fetcher }
);
await expect(service.sendPairingNotification(request)).resolves.toBe(true);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher).toHaveBeenNthCalledWith(
1,
"https://discord.com/api/v10/users/@me/channels",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bot discord-bot-token"
}),
body: JSON.stringify({ recipient_id: "123456789012345678" })
})
);
expect(fetcher).toHaveBeenNthCalledWith(
2,
"https://discord.com/api/v10/channels/dm-channel-1/messages",
expect.objectContaining({
method: "POST"
})
);
});
it("returns false when Discord rejects DM channel creation", async () => {
const fetcher = vi.fn<DiscordFetch>().mockResolvedValueOnce({
ok: false,
status: 403,
json: async () => ({ message: "Missing Access" })
});
const service = createDiscordNotificationService(
{
botToken: "discord-bot-token",
adminUserId: "123456789012345678"
},
{ fetcher }
);
await expect(service.sendPairingNotification(request)).resolves.toBe(false);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("returns false when config is missing required Discord credentials", async () => {
const fetcher = vi.fn<DiscordFetch>();
const service = createDiscordNotificationService(
{
botToken: "",
adminUserId: ""
},
{ fetcher }
);
await expect(service.sendPairingNotification(request)).resolves.toBe(false);
expect(fetcher).not.toHaveBeenCalled();
});
});

View 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);
});
});

View 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 };
}

448
tests/runtime-flow.test.ts Normal file
View File

@@ -0,0 +1,448 @@
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.Protocol/src/crypto.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
};
}
function stubDiscordFetchSuccess() {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: "dm-channel-1" })
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: "message-1" })
})
);
}
describe("Yonexus.Server runtime flow", () => {
it("runs hello -> pair_request for an unpaired client", async () => {
stubDiscordFetchSuccess();
const store = createMockStore();
const transportState = createMockTransport();
const runtime = createYonexusServerRuntime({
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 () => {
stubDiscordFetchSuccess();
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("returns MALFORMED_MESSAGE for hello without payload and keeps the connection open", async () => {
const store = createMockStore();
const transportState = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport: transportState.transport,
now: () => 1_710_000_000
});
await runtime.start();
const connection = createConnection();
await runtime.handleMessage(
connection,
encodeBuiltin({
type: "hello",
requestId: "req-bad-hello",
timestamp: 1_710_000_000
})
);
expect(transportState.sentToConnection).toHaveLength(1);
const errorResponse = decodeBuiltin(transportState.sentToConnection[0].message);
expect(errorResponse.type).toBe("error");
expect(errorResponse.payload).toMatchObject({
code: "MALFORMED_MESSAGE",
message: "hello payload is required"
});
expect((connection.ws.close as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
});
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();
}
});
});

View File

@@ -0,0 +1,303 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createYonexusServerRuntime } from "../plugin/core/runtime.js";
import { createMockNotificationService } from "../plugin/notifications/discord.js";
import {
createYonexusServerStore,
loadServerStore,
YonexusServerStoreCorruptionError
} from "../plugin/core/store.js";
import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js";
import {
buildHello,
decodeBuiltin,
encodeBuiltin,
type BuiltinEnvelope,
type PairRequestPayload,
YONEXUS_PROTOCOL_VERSION
} from "../../Yonexus.Protocol/src/index.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function createTempServerStorePath(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "yonexus-server-recovery-"));
tempDirs.push(dir);
return join(dir, "server-store.json");
}
function createMockSocket() {
return {
close: vi.fn()
} as unknown as ClientConnection["ws"];
}
function createConnection(identifier: string | null = null): ClientConnection {
return {
identifier,
ws: createMockSocket(),
connectedAt: 1_710_000_000,
isAuthenticated: false
};
}
function createMockTransport() {
const sentToConnection: Array<{ connection: ClientConnection; message: string }> = [];
const transport: ServerTransport = {
isRunning: false,
connections: new Map(),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
send: vi.fn(() => true),
sendToConnection: vi.fn((connection: ClientConnection, message: string) => {
sentToConnection.push({ connection, message });
return true;
}),
broadcast: vi.fn(),
closeConnection: vi.fn(() => true),
promoteToAuthenticated: vi.fn(() => true),
removeTempConnection: vi.fn(),
assignIdentifierToTemp: vi.fn()
};
return {
transport,
sentToConnection
};
}
describe("YNX-1105e: Server state recovery", () => {
it("SR-01: preserves pending pairing across restart and reuses the same pairing code", async () => {
const storePath = await createTempServerStorePath();
const store = createYonexusServerStore(storePath);
let now = 1_710_000_000;
const firstTransport = createMockTransport();
const firstRuntime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport: firstTransport.transport,
notificationService: createMockNotificationService(),
now: () => now
});
await firstRuntime.start();
const firstConnection = createConnection();
await firstRuntime.handleMessage(
firstConnection,
encodeBuiltin(
buildHello(
{
identifier: "client-a",
hasSecret: false,
hasKeyPair: false,
protocolVersion: YONEXUS_PROTOCOL_VERSION
},
{ requestId: "req-hello-1", timestamp: now }
)
)
);
const initialRecord = firstRuntime.state.registry.clients.get("client-a");
const initialPairingCode = initialRecord?.pairingCode;
const initialExpiresAt = initialRecord?.pairingExpiresAt;
expect(initialRecord?.pairingStatus).toBe("pending");
expect(initialPairingCode).toBeTypeOf("string");
expect(initialExpiresAt).toBeTypeOf("number");
await firstRuntime.stop();
const persistedRaw = JSON.parse(await readFile(storePath, "utf8")) as {
clients: Array<{ identifier: string; pairingStatus: string; pairingCode?: string }>;
};
expect(
persistedRaw.clients.find((client) => client.identifier === "client-a")
).toMatchObject({
identifier: "client-a",
pairingStatus: "pending",
pairingCode: initialPairingCode
});
now += 30;
const secondTransport = createMockTransport();
const secondRuntime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport: secondTransport.transport,
notificationService: createMockNotificationService(),
now: () => now
});
await secondRuntime.start();
const reloadedRecord = secondRuntime.state.registry.clients.get("client-a");
expect(reloadedRecord?.pairingStatus).toBe("pending");
expect(reloadedRecord?.pairingCode).toBe(initialPairingCode);
expect(reloadedRecord?.pairingExpiresAt).toBe(initialExpiresAt);
const secondConnection = createConnection();
await secondRuntime.handleMessage(
secondConnection,
encodeBuiltin(
buildHello(
{
identifier: "client-a",
hasSecret: false,
hasKeyPair: false,
protocolVersion: YONEXUS_PROTOCOL_VERSION
},
{ requestId: "req-hello-2", timestamp: now }
)
)
);
const helloAck = decodeBuiltin(secondTransport.sentToConnection[0].message);
expect(helloAck.type).toBe("hello_ack");
expect(helloAck.payload).toMatchObject({
identifier: "client-a",
nextAction: "waiting_pair_confirm"
});
const pairRequest = decodeBuiltin(secondTransport.sentToConnection[1].message) as BuiltinEnvelope<
"pair_request",
PairRequestPayload
>;
expect(pairRequest.type).toBe("pair_request");
expect(pairRequest.payload).toMatchObject({
identifier: "client-a",
adminNotification: "sent",
codeDelivery: "out_of_band"
});
expect(pairRequest.payload?.expiresAt).toBe(initialExpiresAt);
await secondRuntime.stop();
});
it("SR-02: restart drops in-memory active sessions and requires reconnect", async () => {
const storePath = await createTempServerStorePath();
const store = createYonexusServerStore(storePath);
const now = 1_710_000_000;
const firstTransport = createMockTransport();
const firstRuntime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport: firstTransport.transport,
now: () => now
});
await firstRuntime.start();
const record = firstRuntime.state.registry.clients.get("client-a");
expect(record).toBeDefined();
record!.pairingStatus = "paired";
record!.publicKey = "test-public-key";
record!.secret = "test-secret";
record!.status = "online";
record!.lastAuthenticatedAt = now;
record!.lastHeartbeatAt = now;
record!.updatedAt = now;
firstRuntime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: createMockSocket(),
isAuthenticated: true,
connectedAt: now,
lastActivityAt: now,
publicKey: "test-public-key"
});
await firstRuntime.stop();
const secondTransport = createMockTransport();
const secondRuntime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport: secondTransport.transport,
now: () => now + 5
});
await secondRuntime.start();
const reloadedRecord = secondRuntime.state.registry.clients.get("client-a");
expect(reloadedRecord).toMatchObject({
identifier: "client-a",
pairingStatus: "paired",
secret: "test-secret",
publicKey: "test-public-key",
status: "online",
lastAuthenticatedAt: now,
lastHeartbeatAt: now
});
expect(secondRuntime.state.registry.sessions.size).toBe(0);
const reconnectConnection = createConnection();
await secondRuntime.handleMessage(
reconnectConnection,
encodeBuiltin(
buildHello(
{
identifier: "client-a",
hasSecret: true,
hasKeyPair: true,
publicKey: "test-public-key",
protocolVersion: YONEXUS_PROTOCOL_VERSION
},
{ requestId: "req-hello-reconnect", timestamp: now + 5 }
)
)
);
const helloAck = decodeBuiltin(secondTransport.sentToConnection[0].message);
expect(helloAck.type).toBe("hello_ack");
expect(helloAck.payload).toMatchObject({
identifier: "client-a",
nextAction: "auth_required"
});
await secondRuntime.stop();
});
it("SR-05: corrupted server store raises YonexusServerStoreCorruptionError", async () => {
const storePath = await createTempServerStorePath();
await writeFile(storePath, '{"version":1,"clients":"oops"}\n', "utf8");
await expect(loadServerStore(storePath)).rejects.toBeInstanceOf(YonexusServerStoreCorruptionError);
await expect(loadServerStore(storePath)).rejects.toThrow("invalid clients array");
});
});

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"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",
"plugin/**/*.d.ts",
"servers/**/*.ts",
"../Yonexus.Protocol/src/**/*.ts"
],
"exclude": [
"dist",
"node_modules"
]
}

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node"
}
});