Compare commits

..

40 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
39 changed files with 7701 additions and 1 deletions

4
.gitignore vendored Normal file
View File

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

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

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

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