Compare commits
42 Commits
23969afa80
...
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 | |||
| 871fe94318 | |||
| d20c3b46ab |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "protocol"]
|
||||
path = protocol
|
||||
url = https://git.hangman-lab.top/nav/Yonexus.Protocol.git
|
||||
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
|
||||
2
PLAN.md
2
PLAN.md
@@ -4,6 +4,8 @@
|
||||
|
||||
`Yonexus.Server` is the OpenClaw plugin that acts as the central communication hub in a Yonexus network.
|
||||
|
||||
This repository references `Yonexus.Protocol` as a submodule at `protocol/`.
|
||||
|
||||
It is responsible for:
|
||||
- accepting WebSocket connections from clients
|
||||
- maintaining the client registry and trust state
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
1
protocol
Submodule
1
protocol
Submodule
Submodule protocol added at 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