Compare commits
40 Commits
871fe94318
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb9be9dee | ||
|
|
b571180b89 | ||
|
|
108590443c | ||
| ea764f637e | |||
| 6bfa0f3f28 | |||
| a8748f8c55 | |||
| 07c670c272 | |||
| 59d5b26aff | |||
| 31f41cb49b | |||
| 2972c4750e | |||
| b67166fd12 | |||
| e038fb7666 | |||
| 9bd62e5ee9 | |||
| 5bda184a8f | |||
| 3c760fc0f4 | |||
| 0717b204f1 | |||
| 35972981d3 | |||
| 4f4c6bf993 | |||
| 35d787be04 | |||
| b8008d9302 | |||
| 25e1867adf | |||
| 988170dcf6 | |||
| 4f20ec3fd7 | |||
| 075fcb7974 | |||
| ba007ebd59 | |||
| 83f6195c1f | |||
| a05b226056 | |||
| cd09fe6043 | |||
| f7c7531385 | |||
| b44a4cae66 | |||
| c5287fa474 | |||
| bc1a002a8c | |||
| 3ec57ce199 | |||
| ac128d3827 | |||
| d8290c0aa7 | |||
| 7673969176 | |||
| 998310e971 | |||
| 162312d16c | |||
| b64d87c532 | |||
| 741c993214 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
184
CONVENTIONS.md
Normal file
184
CONVENTIONS.md
Normal 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
228
MANIFEST.md
Normal 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
145
README.md
@@ -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
249
SCAFFOLD.md
Normal 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 client’s 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
140
STRUCTURE.md
Normal 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
180
TASKS.md
Normal 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
1318
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal 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
0
plugin/commands/.gitkeep
Normal file
0
plugin/core/.gitkeep
Normal file
0
plugin/core/.gitkeep
Normal file
108
plugin/core/config.ts
Normal file
108
plugin/core/config.ts
Normal 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
41
plugin/core/logging.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const DEFAULT_VISIBLE_EDGE = 4;
|
||||||
|
|
||||||
|
export type RedactableValue = string | null | undefined;
|
||||||
|
|
||||||
|
export function redactSecret(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
|
||||||
|
return redactValue(value, { visibleEdge, label: "secret" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactPairingCode(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
|
||||||
|
return redactValue(value, { visibleEdge, label: "pairingCode" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactKey(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
|
||||||
|
return redactValue(value, { visibleEdge, label: "key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactValue(
|
||||||
|
value: RedactableValue,
|
||||||
|
options: { visibleEdge?: number; label?: string } = {}
|
||||||
|
): string {
|
||||||
|
const visibleEdge = options.visibleEdge ?? DEFAULT_VISIBLE_EDGE;
|
||||||
|
const label = options.label ?? "value";
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return `<redacted:${label}:empty>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length <= visibleEdge * 2) {
|
||||||
|
return `<redacted:${label}:${value.length}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, visibleEdge)}…${value.slice(-visibleEdge)} <redacted:${label}:${value.length}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
258
plugin/core/persistence.ts
Normal file
258
plugin/core/persistence.ts
Normal 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
89
plugin/core/rules.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
BUILTIN_RULE,
|
||||||
|
CodecError,
|
||||||
|
parseRewrittenRuleMessage
|
||||||
|
} from "../../../Yonexus.Protocol/src/index.js";
|
||||||
|
|
||||||
|
export type ServerRuleProcessor = (message: string) => unknown;
|
||||||
|
|
||||||
|
export class ServerRuleRegistryError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ServerRuleRegistryError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerRuleRegistry {
|
||||||
|
readonly size: number;
|
||||||
|
registerRule(rule: string, processor: ServerRuleProcessor): void;
|
||||||
|
hasRule(rule: string): boolean;
|
||||||
|
dispatch(raw: string): boolean;
|
||||||
|
getRules(): readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YonexusServerRuleRegistry implements ServerRuleRegistry {
|
||||||
|
private readonly rules = new Map<string, ServerRuleProcessor>();
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.rules.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerRule(rule: string, processor: ServerRuleProcessor): void {
|
||||||
|
const normalizedRule = this.normalizeRule(rule);
|
||||||
|
if (this.rules.has(normalizedRule)) {
|
||||||
|
throw new ServerRuleRegistryError(
|
||||||
|
`Rule '${normalizedRule}' is already registered`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rules.set(normalizedRule, processor);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRule(rule: string): boolean {
|
||||||
|
return this.rules.has(rule.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(raw: string): boolean {
|
||||||
|
const parsed = parseRewrittenRuleMessage(raw);
|
||||||
|
const processor = this.rules.get(parsed.ruleIdentifier);
|
||||||
|
if (!processor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
processor(raw);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRules(): readonly string[] {
|
||||||
|
return [...this.rules.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRule(rule: string): string {
|
||||||
|
const normalizedRule = rule.trim();
|
||||||
|
if (!normalizedRule) {
|
||||||
|
throw new ServerRuleRegistryError("Rule identifier must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedRule === BUILTIN_RULE) {
|
||||||
|
throw new ServerRuleRegistryError(
|
||||||
|
`Rule identifier '${BUILTIN_RULE}' is reserved`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
parseRewrittenRuleMessage(`${normalizedRule}::sender::probe`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CodecError) {
|
||||||
|
throw new ServerRuleRegistryError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedRule;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerRuleRegistry(): ServerRuleRegistry {
|
||||||
|
return new YonexusServerRuleRegistry();
|
||||||
|
}
|
||||||
956
plugin/core/runtime.ts
Normal file
956
plugin/core/runtime.ts
Normal 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
181
plugin/core/store.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
deserializeClientRecord,
|
||||||
|
serializeClientRecord,
|
||||||
|
type ClientRecord,
|
||||||
|
type SerializedClientRecord,
|
||||||
|
type ServerPersistenceData
|
||||||
|
} from "./persistence.js";
|
||||||
|
|
||||||
|
export const SERVER_PERSISTENCE_VERSION = 1;
|
||||||
|
|
||||||
|
export class YonexusServerStoreError extends Error {
|
||||||
|
override readonly cause?: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, cause?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = "YonexusServerStoreError";
|
||||||
|
this.cause = cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YonexusServerStoreCorruptionError extends YonexusServerStoreError {
|
||||||
|
constructor(message: string, cause?: unknown) {
|
||||||
|
super(message, cause);
|
||||||
|
this.name = "YonexusServerStoreCorruptionError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerStoreLoadResult {
|
||||||
|
readonly version: number;
|
||||||
|
readonly persistedAt?: number;
|
||||||
|
readonly clients: Map<string, ClientRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YonexusServerStore {
|
||||||
|
readonly filePath: string;
|
||||||
|
load(): Promise<ServerStoreLoadResult>;
|
||||||
|
save(clients: Iterable<ClientRecord>): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createYonexusServerStore(filePath: string): YonexusServerStore {
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
load: async () => loadServerStore(filePath),
|
||||||
|
save: async (clients) => saveServerStore(filePath, clients)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadServerStore(filePath: string): Promise<ServerStoreLoadResult> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as ServerPersistenceData;
|
||||||
|
assertPersistenceDataShape(parsed, filePath);
|
||||||
|
|
||||||
|
const clients = new Map<string, ClientRecord>();
|
||||||
|
for (const serialized of parsed.clients) {
|
||||||
|
assertSerializedClientRecordShape(serialized, filePath);
|
||||||
|
clients.set(serialized.identifier, deserializeClientRecord(serialized));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: parsed.version,
|
||||||
|
persistedAt: parsed.persistedAt,
|
||||||
|
clients
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isFileNotFoundError(error)) {
|
||||||
|
return {
|
||||||
|
version: SERVER_PERSISTENCE_VERSION,
|
||||||
|
clients: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof YonexusServerStoreError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Failed to load Yonexus.Server persistence file: ${filePath}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveServerStore(
|
||||||
|
filePath: string,
|
||||||
|
clients: Iterable<ClientRecord>
|
||||||
|
): Promise<void> {
|
||||||
|
const payload: ServerPersistenceData = {
|
||||||
|
version: SERVER_PERSISTENCE_VERSION,
|
||||||
|
persistedAt: Math.floor(Date.now() / 1000),
|
||||||
|
clients: Array.from(clients, (record) => serializeClientRecord(record))
|
||||||
|
};
|
||||||
|
|
||||||
|
const tempPath = `${filePath}.tmp`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||||
|
await rename(tempPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new YonexusServerStoreError(
|
||||||
|
`Failed to save Yonexus.Server persistence file: ${filePath}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPersistenceDataShape(
|
||||||
|
value: unknown,
|
||||||
|
filePath: string
|
||||||
|
): asserts value is ServerPersistenceData {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file is not a JSON object: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<ServerPersistenceData>;
|
||||||
|
if (candidate.version !== SERVER_PERSISTENCE_VERSION) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Unsupported persistence version in ${filePath}: ${String(candidate.version)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(candidate.clients)) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file has invalid clients array: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
candidate.persistedAt !== undefined &&
|
||||||
|
(!Number.isInteger(candidate.persistedAt) || candidate.persistedAt < 0)
|
||||||
|
) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file has invalid persistedAt value: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSerializedClientRecordShape(
|
||||||
|
value: unknown,
|
||||||
|
filePath: string
|
||||||
|
): asserts value is SerializedClientRecord {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file contains a non-object client record: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<SerializedClientRecord>;
|
||||||
|
if (typeof candidate.identifier !== "string" || candidate.identifier.trim().length === 0) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file contains a client record with invalid identifier: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof candidate.pairingStatus !== "string" || typeof candidate.status !== "string") {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file contains a client record with invalid state fields: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.updatedAt)) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file contains a client record with invalid timestamps: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"code" in error &&
|
||||||
|
(error as { code?: unknown }).code === "ENOENT"
|
||||||
|
);
|
||||||
|
}
|
||||||
295
plugin/core/transport.ts
Normal file
295
plugin/core/transport.ts
Normal 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
42
plugin/crypto/utils.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure random pairing code.
|
||||||
|
* Format: XXXX-XXXX-XXXX (12 alphanumeric characters in groups of 4)
|
||||||
|
* Excludes confusing characters: 0, O, 1, I
|
||||||
|
*/
|
||||||
|
export function generatePairingCode(): string {
|
||||||
|
const bytes = randomBytes(8);
|
||||||
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Excludes confusing chars (0, O, 1, I)
|
||||||
|
|
||||||
|
let code = "";
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
code += chars[bytes[i % bytes.length] % chars.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format as XXXX-XXXX-XXXX
|
||||||
|
return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a shared secret for client authentication.
|
||||||
|
* This is issued by the server after successful pairing.
|
||||||
|
* Returns a base64url-encoded 32-byte random string.
|
||||||
|
*/
|
||||||
|
export function generateSecret(): string {
|
||||||
|
return randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a 24-character nonce for authentication.
|
||||||
|
* Uses base64url encoding of 18 random bytes, truncated to 24 chars.
|
||||||
|
*/
|
||||||
|
export function generateNonce(): string {
|
||||||
|
const bytes = randomBytes(18);
|
||||||
|
return bytes.toString("base64url").slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default pairing code TTL in seconds (5 minutes)
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PAIRING_TTL_SECONDS = 300;
|
||||||
0
plugin/hooks/.gitkeep
Normal file
0
plugin/hooks/.gitkeep
Normal file
258
plugin/index.ts
Normal file
258
plugin/index.ts
Normal 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 };
|
||||||
191
plugin/notifications/discord.ts
Normal file
191
plugin/notifications/discord.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
24
plugin/openclaw.plugin.json
Normal file
24
plugin/openclaw.plugin.json
Normal 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
190
plugin/services/pairing.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Yonexus Server - Pairing Service
|
||||||
|
*
|
||||||
|
* Manages client pairing flow:
|
||||||
|
* - Creating pairing requests with codes
|
||||||
|
* - Tracking pairing expiration
|
||||||
|
* - Validating pairing confirmations
|
||||||
|
* - Issuing shared secrets after successful pairing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ClientRecord } from "../core/persistence.js";
|
||||||
|
import { generatePairingCode, generateSecret, DEFAULT_PAIRING_TTL_SECONDS } from "../crypto/utils.js";
|
||||||
|
|
||||||
|
export interface PairingRequest {
|
||||||
|
readonly identifier: string;
|
||||||
|
readonly pairingCode: string;
|
||||||
|
readonly expiresAt: number;
|
||||||
|
readonly ttlSeconds: number;
|
||||||
|
readonly createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingResult {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly secret?: string;
|
||||||
|
readonly pairedAt?: number;
|
||||||
|
readonly reason?: PairingFailureReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PairingFailureReason =
|
||||||
|
| "expired"
|
||||||
|
| "invalid_code"
|
||||||
|
| "not_pending"
|
||||||
|
| "internal_error";
|
||||||
|
|
||||||
|
export class PairingService {
|
||||||
|
private readonly now: () => number;
|
||||||
|
|
||||||
|
constructor(options: { now?: () => number } = {}) {
|
||||||
|
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new pairing request for a client.
|
||||||
|
* Updates the client record with pending pairing state.
|
||||||
|
*/
|
||||||
|
createPairingRequest(
|
||||||
|
record: ClientRecord,
|
||||||
|
options: { ttlSeconds?: number } = {}
|
||||||
|
): PairingRequest {
|
||||||
|
const ttlSeconds = options.ttlSeconds ?? DEFAULT_PAIRING_TTL_SECONDS;
|
||||||
|
const now = this.now();
|
||||||
|
const pairingCode = generatePairingCode();
|
||||||
|
|
||||||
|
// Update the client record
|
||||||
|
record.pairingStatus = "pending";
|
||||||
|
record.pairingCode = pairingCode;
|
||||||
|
record.pairingExpiresAt = now + ttlSeconds;
|
||||||
|
record.pairingNotifyStatus = "pending";
|
||||||
|
record.updatedAt = now;
|
||||||
|
|
||||||
|
return {
|
||||||
|
identifier: record.identifier,
|
||||||
|
pairingCode,
|
||||||
|
expiresAt: record.pairingExpiresAt,
|
||||||
|
ttlSeconds,
|
||||||
|
createdAt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a pairing confirmation from a client.
|
||||||
|
* Returns the pairing result and updates the record on success.
|
||||||
|
*/
|
||||||
|
confirmPairing(
|
||||||
|
record: ClientRecord,
|
||||||
|
submittedCode: string
|
||||||
|
): PairingResult {
|
||||||
|
const now = this.now();
|
||||||
|
|
||||||
|
// Check if pairing is pending
|
||||||
|
if (record.pairingStatus !== "pending") {
|
||||||
|
return { success: false, reason: "not_pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pairing has expired
|
||||||
|
if (record.pairingExpiresAt && now > record.pairingExpiresAt) {
|
||||||
|
this.clearPairingState(record);
|
||||||
|
return { success: false, reason: "expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the pairing code
|
||||||
|
if (record.pairingCode !== submittedCode) {
|
||||||
|
return { success: false, reason: "invalid_code" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pairing successful - generate secret and update record
|
||||||
|
const secret = generateSecret();
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.secret = secret;
|
||||||
|
record.pairedAt = now;
|
||||||
|
record.updatedAt = now;
|
||||||
|
|
||||||
|
// Clear pairing-specific fields
|
||||||
|
record.pairingCode = undefined;
|
||||||
|
record.pairingExpiresAt = undefined;
|
||||||
|
record.pairingNotifiedAt = undefined;
|
||||||
|
record.pairingNotifyStatus = undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
secret,
|
||||||
|
pairedAt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a pairing notification as sent.
|
||||||
|
*/
|
||||||
|
markNotificationSent(record: ClientRecord): void {
|
||||||
|
if (record.pairingStatus === "pending") {
|
||||||
|
record.pairingNotifyStatus = "sent";
|
||||||
|
record.pairingNotifiedAt = this.now();
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a pairing notification as failed.
|
||||||
|
*/
|
||||||
|
markNotificationFailed(record: ClientRecord): void {
|
||||||
|
if (record.pairingStatus === "pending") {
|
||||||
|
record.pairingNotifyStatus = "failed";
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear pairing state for a client.
|
||||||
|
* Used when pairing fails or is cancelled.
|
||||||
|
*/
|
||||||
|
clearPairingState(record: ClientRecord): void {
|
||||||
|
record.pairingStatus = record.pairingStatus === "paired" ? "paired" : "unpaired";
|
||||||
|
record.pairingCode = undefined;
|
||||||
|
record.pairingExpiresAt = undefined;
|
||||||
|
record.pairingNotifiedAt = undefined;
|
||||||
|
record.pairingNotifyStatus = undefined;
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke pairing for a client.
|
||||||
|
* Clears secret and returns to unpaired state.
|
||||||
|
*/
|
||||||
|
revokePairing(record: ClientRecord): void {
|
||||||
|
record.pairingStatus = "revoked";
|
||||||
|
record.secret = undefined;
|
||||||
|
record.publicKey = undefined;
|
||||||
|
record.pairedAt = undefined;
|
||||||
|
this.clearPairingState(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a pairing request is expired.
|
||||||
|
*/
|
||||||
|
isExpired(record: ClientRecord): boolean {
|
||||||
|
if (!record.pairingExpiresAt) return false;
|
||||||
|
return this.now() > record.pairingExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining TTL for a pending pairing.
|
||||||
|
* Returns 0 if expired or not pending.
|
||||||
|
*/
|
||||||
|
getRemainingTtl(record: ClientRecord): number {
|
||||||
|
if (record.pairingStatus !== "pending" || !record.pairingExpiresAt) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const remaining = record.pairingExpiresAt - this.now();
|
||||||
|
return Math.max(0, remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a pairing service.
|
||||||
|
*/
|
||||||
|
export function createPairingService(
|
||||||
|
options: { now?: () => number } = {}
|
||||||
|
): PairingService {
|
||||||
|
return new PairingService(options);
|
||||||
|
}
|
||||||
0
plugin/tools/.gitkeep
Normal file
0
plugin/tools/.gitkeep
Normal file
21
plugin/types/ws.d.ts
vendored
Normal file
21
plugin/types/ws.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
protocol
2
protocol
Submodule protocol updated: 9232aa7c17...ccdf167daf
79
scripts/install.mjs
Normal file
79
scripts/install.mjs
Normal 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
0
servers/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
705
tests/auth-failures.test.ts
Normal file
705
tests/auth-failures.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
297
tests/connection-heartbeat-failures.test.ts
Normal file
297
tests/connection-heartbeat-failures.test.ts
Normal 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
107
tests/notifications.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
102
tests/pairing-and-rules.test.ts
Normal file
102
tests/pairing-and-rules.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createClientRecord } from "../plugin/core/persistence.js";
|
||||||
|
import { createServerRuleRegistry, ServerRuleRegistryError } from "../plugin/core/rules.js";
|
||||||
|
import { createPairingService } from "../plugin/services/pairing.js";
|
||||||
|
|
||||||
|
describe("Yonexus.Server PairingService", () => {
|
||||||
|
it("creates a pending pairing request with ttl metadata", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||||
|
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 180 });
|
||||||
|
|
||||||
|
expect(request.identifier).toBe("client-a");
|
||||||
|
expect(request.ttlSeconds).toBe(180);
|
||||||
|
expect(request.expiresAt).toBe(1_710_000_180);
|
||||||
|
expect(record.pairingStatus).toBe("pending");
|
||||||
|
expect(record.pairingCode).toBe(request.pairingCode);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms a valid pairing and clears pairing-only fields", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_100 });
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 300 });
|
||||||
|
|
||||||
|
const result = pairing.confirmPairing(record, request.pairingCode);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
pairedAt: 1_710_000_100
|
||||||
|
});
|
||||||
|
expect(typeof result.secret).toBe("string");
|
||||||
|
expect(record.pairingStatus).toBe("paired");
|
||||||
|
expect(record.secret).toBe(result.secret);
|
||||||
|
expect(record.pairingCode).toBeUndefined();
|
||||||
|
expect(record.pairingExpiresAt).toBeUndefined();
|
||||||
|
expect(record.pairingNotifyStatus).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects expired and invalid pairing confirmations without dirtying state", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
let now = 1_710_000_000;
|
||||||
|
const pairing = createPairingService({ now: () => now });
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 60 });
|
||||||
|
|
||||||
|
const invalid = pairing.confirmPairing(record, "WRONG-CODE-000");
|
||||||
|
expect(invalid).toEqual({ success: false, reason: "invalid_code" });
|
||||||
|
expect(record.pairingStatus).toBe("pending");
|
||||||
|
expect(record.pairingCode).toBe(request.pairingCode);
|
||||||
|
|
||||||
|
now = 1_710_000_100;
|
||||||
|
const expired = pairing.confirmPairing(record, request.pairingCode);
|
||||||
|
expect(expired).toEqual({ success: false, reason: "expired" });
|
||||||
|
expect(record.pairingStatus).toBe("unpaired");
|
||||||
|
expect(record.pairingCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks notification delivery state transitions", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||||
|
pairing.createPairingRequest(record);
|
||||||
|
|
||||||
|
pairing.markNotificationSent(record);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("sent");
|
||||||
|
expect(record.pairingNotifiedAt).toBe(1_710_000_000);
|
||||||
|
|
||||||
|
pairing.markNotificationFailed(record);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Yonexus.Server RuleRegistry", () => {
|
||||||
|
it("dispatches exact-match rewritten messages to the registered processor", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
const processor = vi.fn();
|
||||||
|
registry.registerRule("chat_sync", processor);
|
||||||
|
|
||||||
|
const handled = registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(processor).toHaveBeenCalledWith("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
expect(registry.hasRule("chat_sync")).toBe(true);
|
||||||
|
expect(registry.getRules()).toEqual(["chat_sync"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reserved and duplicate rule registrations", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
registry.registerRule("chat_sync", () => undefined);
|
||||||
|
|
||||||
|
expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ServerRuleRegistryError);
|
||||||
|
expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow(
|
||||||
|
"Rule 'chat_sync' is already registered"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no processor matches a rewritten message", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
|
||||||
|
expect(registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
486
tests/pairing-auth-liveness.test.ts
Normal file
486
tests/pairing-auth-liveness.test.ts
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createClientRecord, type ClientRecord } from "../plugin/core/persistence.js";
|
||||||
|
import { createServerRuleRegistry, ServerRuleRegistryError } from "../plugin/core/rules.js";
|
||||||
|
import { createPairingService } from "../plugin/services/pairing.js";
|
||||||
|
|
||||||
|
// Inline protocol helpers (to avoid submodule dependency in tests)
|
||||||
|
function createAuthRequestSigningInput(input: {
|
||||||
|
secret: string;
|
||||||
|
nonce: string;
|
||||||
|
proofTimestamp: number;
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
secret: input.secret,
|
||||||
|
nonce: input.nonce,
|
||||||
|
timestamp: input.proofTimestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTimestampFresh(
|
||||||
|
proofTimestamp: number,
|
||||||
|
now: number,
|
||||||
|
maxDriftSeconds: number = 10
|
||||||
|
): { ok: true } | { ok: false; reason: "stale_timestamp" | "future_timestamp" } {
|
||||||
|
const drift = proofTimestamp - now;
|
||||||
|
if (Math.abs(drift) < maxDriftSeconds) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, reason: drift < 0 ? "stale_timestamp" : "future_timestamp" };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Yonexus.Server PairingService", () => {
|
||||||
|
it("creates a pending pairing request with ttl metadata", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||||
|
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 180 });
|
||||||
|
|
||||||
|
expect(request.identifier).toBe("client-a");
|
||||||
|
expect(request.ttlSeconds).toBe(180);
|
||||||
|
expect(request.expiresAt).toBe(1_710_000_180);
|
||||||
|
expect(record.pairingStatus).toBe("pending");
|
||||||
|
expect(record.pairingCode).toBe(request.pairingCode);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms a valid pairing and clears pairing-only fields", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_100 });
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 300 });
|
||||||
|
|
||||||
|
const result = pairing.confirmPairing(record, request.pairingCode);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
pairedAt: 1_710_000_100
|
||||||
|
});
|
||||||
|
expect(typeof result.secret).toBe("string");
|
||||||
|
expect(record.pairingStatus).toBe("paired");
|
||||||
|
expect(record.secret).toBe(result.secret);
|
||||||
|
expect(record.pairingCode).toBeUndefined();
|
||||||
|
expect(record.pairingExpiresAt).toBeUndefined();
|
||||||
|
expect(record.pairingNotifyStatus).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects expired and invalid pairing confirmations without dirtying state", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
let now = 1_710_000_000;
|
||||||
|
const pairing = createPairingService({ now: () => now });
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 60 });
|
||||||
|
|
||||||
|
const invalid = pairing.confirmPairing(record, "WRONG-CODE-000");
|
||||||
|
expect(invalid).toEqual({ success: false, reason: "invalid_code" });
|
||||||
|
expect(record.pairingStatus).toBe("pending");
|
||||||
|
expect(record.pairingCode).toBe(request.pairingCode);
|
||||||
|
|
||||||
|
now = 1_710_000_100;
|
||||||
|
const expired = pairing.confirmPairing(record, request.pairingCode);
|
||||||
|
expect(expired).toEqual({ success: false, reason: "expired" });
|
||||||
|
expect(record.pairingStatus).toBe("unpaired");
|
||||||
|
expect(record.pairingCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks notification delivery state transitions", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||||
|
pairing.createPairingRequest(record);
|
||||||
|
|
||||||
|
pairing.markNotificationSent(record);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("sent");
|
||||||
|
expect(record.pairingNotifiedAt).toBe(1_710_000_000);
|
||||||
|
|
||||||
|
pairing.markNotificationFailed(record);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Yonexus.Server RuleRegistry", () => {
|
||||||
|
it("dispatches exact-match rewritten messages to the registered processor", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
const processor = vi.fn();
|
||||||
|
registry.registerRule("chat_sync", processor);
|
||||||
|
|
||||||
|
const handled = registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(processor).toHaveBeenCalledWith("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
expect(registry.hasRule("chat_sync")).toBe(true);
|
||||||
|
expect(registry.getRules()).toEqual(["chat_sync"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reserved and duplicate rule registrations", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
registry.registerRule("chat_sync", () => undefined);
|
||||||
|
|
||||||
|
expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ServerRuleRegistryError);
|
||||||
|
expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow(
|
||||||
|
"Rule 'chat_sync' is already registered"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no processor matches a rewritten message", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
|
||||||
|
expect(registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Yonexus.Server Auth Service", () => {
|
||||||
|
it("verifies valid auth request payload", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "test-pk";
|
||||||
|
record.secret = "test-secret";
|
||||||
|
|
||||||
|
const nonce = "RANDOM24CHARACTERSTRINGX";
|
||||||
|
const timestamp = 1_710_000_000;
|
||||||
|
const signingInput = createAuthRequestSigningInput({
|
||||||
|
secret: "test-secret",
|
||||||
|
nonce,
|
||||||
|
proofTimestamp: timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock signature verification (in real impl would use crypto)
|
||||||
|
const mockSignature = `signed:${signingInput}`;
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce,
|
||||||
|
proofTimestamp: timestamp,
|
||||||
|
signature: mockSignature,
|
||||||
|
publicKey: "test-pk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
now: () => timestamp,
|
||||||
|
verifySignature: (sig, input) => sig === `signed:${input}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result).toHaveProperty("authenticatedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth for unpaired client", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{ now: () => 1_710_000_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("not_paired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with mismatched public key", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "expected-pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "different-pk"
|
||||||
|
},
|
||||||
|
{ now: () => 1_710_000_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("public_key_mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with stale timestamp", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
now: () => 1_710_000_100
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("stale_timestamp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with future timestamp", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_100,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{ now: () => 1_710_000_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("future_timestamp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with nonce collision", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
record.recentNonces = [{ nonce: "COLLIDING24CHARSTRINGX", timestamp: 1_710_000_000 }];
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "COLLIDING24CHARSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_010,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{ now: () => 1_710_000_010 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("nonce_collision");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with rate limit exceeded", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
const now = 1_710_000_000;
|
||||||
|
record.recentHandshakeAttempts = Array(11).fill(now - 5);
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARSTRINGX01",
|
||||||
|
proofTimestamp: now,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{ now: () => now }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("rate_limited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalid signature triggers re_pair_required", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARSTRINGX01",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "invalid-sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
now: () => 1_710_000_000,
|
||||||
|
verifySignature: () => false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("re_pair_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks successful auth attempt in record", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const now = 1_710_000_000;
|
||||||
|
const nonce = "RANDOM24CHARSTRINGX01";
|
||||||
|
|
||||||
|
const signingInput = createAuthRequestSigningInput({
|
||||||
|
secret: "secret",
|
||||||
|
nonce,
|
||||||
|
proofTimestamp: now
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce,
|
||||||
|
proofTimestamp: now,
|
||||||
|
signature: `signed:${signingInput}`,
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
now: () => now,
|
||||||
|
verifySignature: (sig, input) => sig === `signed:${input}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(record.recentNonces).toContainEqual({ nonce, timestamp: now });
|
||||||
|
expect(record.recentHandshakeAttempts).toContain(now);
|
||||||
|
expect(record.lastAuthenticatedAt).toBe(now);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Yonexus.Server Heartbeat / Liveness", () => {
|
||||||
|
it("evaluates client online when recent heartbeat exists", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.lastHeartbeatAt = 1_710_000_000;
|
||||||
|
record.status = "online";
|
||||||
|
|
||||||
|
const status = evaluateLiveness(record, { now: () => 1_710_000_300 });
|
||||||
|
expect(status).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates client unstable after 7 minutes without heartbeat", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.lastHeartbeatAt = 1_710_000_000;
|
||||||
|
record.status = "online";
|
||||||
|
|
||||||
|
const status = evaluateLiveness(record, { now: () => 1_710_000_420 });
|
||||||
|
expect(status).toBe("unstable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates client offline after 11 minutes without heartbeat", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.lastHeartbeatAt = 1_710_000_000;
|
||||||
|
record.status = "online";
|
||||||
|
|
||||||
|
const status = evaluateLiveness(record, { now: () => 1_710_000_660 });
|
||||||
|
expect(status).toBe("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles client with no heartbeat record", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
|
||||||
|
const status = evaluateLiveness(record, { now: () => 1_710_000_000 });
|
||||||
|
expect(status).toBe("offline");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function evaluateLiveness(
|
||||||
|
record: ReturnType<typeof createClientRecord>,
|
||||||
|
options: { now: () => number }
|
||||||
|
): "online" | "unstable" | "offline" {
|
||||||
|
const now = options.now();
|
||||||
|
const lastHeartbeat = record.lastHeartbeatAt;
|
||||||
|
|
||||||
|
if (!lastHeartbeat) {
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = now - lastHeartbeat;
|
||||||
|
|
||||||
|
if (elapsed >= 11 * 60) {
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
|
if (elapsed >= 7 * 60) {
|
||||||
|
return "unstable";
|
||||||
|
}
|
||||||
|
return "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthRequestPayload {
|
||||||
|
identifier: string;
|
||||||
|
nonce: string;
|
||||||
|
proofTimestamp: number;
|
||||||
|
signature: string;
|
||||||
|
publicKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthVerifyResult {
|
||||||
|
success: boolean;
|
||||||
|
reason?: string;
|
||||||
|
authenticatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAuthRequest(
|
||||||
|
record: ClientRecord,
|
||||||
|
payload: AuthRequestPayload,
|
||||||
|
options: {
|
||||||
|
now: () => number;
|
||||||
|
verifySignature?: (signature: string, input: string) => boolean;
|
||||||
|
}
|
||||||
|
): AuthVerifyResult {
|
||||||
|
if (record.pairingStatus !== "paired") {
|
||||||
|
return { success: false, reason: "not_paired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.publicKey && record.publicKey !== payload.publicKey) {
|
||||||
|
return { success: false, reason: "public_key_mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampCheck = isTimestampFresh(payload.proofTimestamp, options.now());
|
||||||
|
if (!timestampCheck.ok) {
|
||||||
|
return { success: false, reason: timestampCheck.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonceCollision = record.recentNonces.some((n) => n.nonce === payload.nonce);
|
||||||
|
if (nonceCollision) {
|
||||||
|
return { success: false, reason: "nonce_collision" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = options.now();
|
||||||
|
const recentAttempts = record.recentHandshakeAttempts.filter((t) => now - t < 10_000);
|
||||||
|
if (recentAttempts.length >= 10) {
|
||||||
|
return { success: false, reason: "rate_limited" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingInput = createAuthRequestSigningInput({
|
||||||
|
secret: record.secret!,
|
||||||
|
nonce: payload.nonce,
|
||||||
|
proofTimestamp: payload.proofTimestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValidSignature = options.verifySignature?.(payload.signature, signingInput) ?? true;
|
||||||
|
if (!isValidSignature) {
|
||||||
|
return { success: false, reason: "re_pair_required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
record.recentNonces.push({ nonce: payload.nonce, timestamp: now });
|
||||||
|
if (record.recentNonces.length > 10) {
|
||||||
|
record.recentNonces.shift();
|
||||||
|
}
|
||||||
|
record.recentHandshakeAttempts.push(now);
|
||||||
|
record.lastAuthenticatedAt = now;
|
||||||
|
record.lastHeartbeatAt = now;
|
||||||
|
|
||||||
|
return { success: true, authenticatedAt: now };
|
||||||
|
}
|
||||||
448
tests/runtime-flow.test.ts
Normal file
448
tests/runtime-flow.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
303
tests/state-recovery.test.ts
Normal file
303
tests/state-recovery.test.ts
Normal 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
26
tsconfig.json
Normal 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
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node"
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user