diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6d5efa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +*.log diff --git a/README.md b/README.md index e69de29..d723e9d 100644 --- a/README.md +++ b/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": "", + "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 +sendRuleMessageToClient(identifier: string, ruleIdentifier: string, content: string): Promise +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` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d16eb28 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1318 @@ +{ + "name": "yonexus-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yonexus-server", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "typescript": "^5.6.3", + "vitest": "^4.1.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef8d99a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/plugin/commands/.gitkeep b/plugin/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/core/.gitkeep b/plugin/core/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/core/config.ts b/plugin/core/config.ts new file mode 100644 index 0000000..eee4bc8 --- /dev/null +++ b/plugin/core/config.ts @@ -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; + const issues: string[] = []; + + const rawIdentifiers = source.followerIdentifiers; + const followerIdentifiers = Array.isArray(rawIdentifiers) + ? rawIdentifiers + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + : []; + + if (!Array.isArray(rawIdentifiers) || followerIdentifiers.length === 0) { + issues.push("followerIdentifiers must contain at least one non-empty identifier"); + } + + if (new Set(followerIdentifiers).size !== followerIdentifiers.length) { + issues.push("followerIdentifiers must not contain duplicates"); + } + + const 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 + }; +} diff --git a/plugin/core/logging.ts b/plugin/core/logging.ts new file mode 100644 index 0000000..44c49f2 --- /dev/null +++ b/plugin/core/logging.ts @@ -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 ``; + } + + if (value.length <= visibleEdge * 2) { + return ``; + } + + return `${value.slice(0, visibleEdge)}…${value.slice(-visibleEdge)} `; +} + +export function safeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} diff --git a/plugin/core/persistence.ts b/plugin/core/persistence.ts new file mode 100644 index 0000000..80705cd --- /dev/null +++ b/plugin/core/persistence.ts @@ -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; + + /** Active WebSocket sessions keyed by identifier */ + sessions: Map; +} + +/** + * 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; +} diff --git a/plugin/core/rules.ts b/plugin/core/rules.ts new file mode 100644 index 0000000..abb60ee --- /dev/null +++ b/plugin/core/rules.ts @@ -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(); + + 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(); +} diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts new file mode 100644 index 0000000..8f0bd58 --- /dev/null +++ b/plugin/core/runtime.ts @@ -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.Client/plugin/crypto/keypair.js"; +import type { YonexusServerStore } from "./store.js"; +import { type ClientConnection, type ServerTransport } from "./transport.js"; +import { createPairingService, type PairingService } from "../services/pairing.js"; +import { + createDiscordNotificationService, + type DiscordNotificationService +} from "../notifications/discord.js"; +import { safeErrorMessage } from "./logging.js"; +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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); +} diff --git a/plugin/core/store.ts b/plugin/core/store.ts new file mode 100644 index 0000000..18e1337 --- /dev/null +++ b/plugin/core/store.ts @@ -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; +} + +export interface YonexusServerStore { + readonly filePath: string; + load(): Promise; + save(clients: Iterable): Promise; +} + +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 { + try { + const raw = await readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as ServerPersistenceData; + assertPersistenceDataShape(parsed, filePath); + + const clients = new Map(); + 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 +): Promise { + 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; + 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; + 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" + ); +} diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts new file mode 100644 index 0000000..a7d01bd --- /dev/null +++ b/plugin/core/transport.ts @@ -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; + start(): Promise; + stop(): Promise; + 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(); + private tempConnections = new Map(); + private options: ServerTransportOptions; + private _isRunning = false; + + constructor(options: ServerTransportOptions) { + this.options = options; + } + + get isRunning(): boolean { + return this._isRunning; + } + + get connections(): ReadonlyMap { + return this._connections; + } + + async start(): Promise { + 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 { + 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); +} diff --git a/plugin/crypto/utils.ts b/plugin/crypto/utils.ts new file mode 100644 index 0000000..29dd51b --- /dev/null +++ b/plugin/crypto/utils.ts @@ -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; diff --git a/plugin/hooks/.gitkeep b/plugin/hooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/index.ts b/plugin/index.ts index e69de29..dea52aa 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -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; +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 ") + .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 | 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 }; diff --git a/plugin/notifications/discord.ts b/plugin/notifications/discord.ts new file mode 100644 index 0000000..b10a342 --- /dev/null +++ b/plugin/notifications/discord.ts @@ -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; +} + +export interface DiscordNotificationConfig { + botToken: string; + adminUserId: string; +} + +export interface DiscordApiResponse { + ok: boolean; + status: number; + json(): Promise; +} + +export type DiscordFetch = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + } +) => Promise; + +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 { + 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 { + 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; +} + +/** + * 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 { + 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; + } + }; +} diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index e69de29..3cef357 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -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": ["followerIdentifiers", "notifyBotToken", "adminUserId", "listenPort"] + } +} diff --git a/plugin/services/pairing.ts b/plugin/services/pairing.ts new file mode 100644 index 0000000..3245942 --- /dev/null +++ b/plugin/services/pairing.ts @@ -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); +} diff --git a/plugin/tools/.gitkeep b/plugin/tools/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/types/ws.d.ts b/plugin/types/ws.d.ts new file mode 100644 index 0000000..b87451f --- /dev/null +++ b/plugin/types/ws.d.ts @@ -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; + } +} diff --git a/protocol b/protocol index 9232aa7..2611304 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 9232aa7c1755adda6990a5a2f6c7c1a114285a73 +Subproject commit 26113040844cc6804e6a2b617d0c9ce1cbdb92df diff --git a/scripts/install.mjs b/scripts/install.mjs index e69de29..9fe3a18 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const args = process.argv.slice(2); +const mode = args.includes("--install") ? "install" : args.includes("--uninstall") ? "uninstall" : null; +const profileIndex = args.indexOf("--openclaw-profile-path"); +const profilePath = profileIndex >= 0 ? args[profileIndex + 1] : path.join(os.homedir(), ".openclaw"); + +if (!mode) { + console.error("Usage: node scripts/install.mjs --install|--uninstall [--openclaw-profile-path ]"); + process.exit(1); +} + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const pluginName = "Yonexus.Server"; +const sourceDist = path.join(repoRoot, "dist"); +const targetDir = path.join(profilePath, "plugins", pluginName); + +if (mode === "install") { + if (!fs.existsSync(sourceDist)) { + console.error(`Build output not found: ${sourceDist}`); + process.exit(1); + } + + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.cpSync(sourceDist, path.join(targetDir, "dist"), { recursive: true }); + fs.copyFileSync(path.join(repoRoot, "plugin", "openclaw.plugin.json"), path.join(targetDir, "openclaw.plugin.json")); + fs.copyFileSync(path.join(repoRoot, "package.json"), path.join(targetDir, "package.json")); + console.log(`Installed ${pluginName} to ${targetDir}`); + process.exit(0); +} + +fs.rmSync(targetDir, { recursive: true, force: true }); +console.log(`Removed ${pluginName} from ${targetDir}`); diff --git a/servers/.gitkeep b/servers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/skills/.gitkeep b/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/auth-failures.test.ts b/tests/auth-failures.test.ts new file mode 100644 index 0000000..85d72f5 --- /dev/null +++ b/tests/auth-failures.test.ts @@ -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.Client/plugin/crypto/keypair.js"; + +function createMockSocket() { + return { close: vi.fn() } as unknown as ClientConnection["ws"]; +} + +function createConnection(identifier: string | null = null): ClientConnection { + return { + identifier, + ws: createMockSocket(), + connectedAt: 1_710_000_000, + isAuthenticated: false + }; +} + +function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore { + const persisted = new Map(initialClients.map((record) => [record.identifier, record])); + + return { + filePath: "/tmp/yonexus-server-auth-failures.json", + load: vi.fn(async () => ({ + version: 1, + persistedAt: 1_710_000_000, + clients: new Map(persisted) + })), + save: vi.fn(async (clients: Iterable) => { + 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" }); + }); +}); diff --git a/tests/connection-heartbeat-failures.test.ts b/tests/connection-heartbeat-failures.test.ts new file mode 100644 index 0000000..e9e43f3 --- /dev/null +++ b/tests/connection-heartbeat-failures.test.ts @@ -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) => { + 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(); + const connections = new Map(); + + 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); + }); +}); diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts new file mode 100644 index 0000000..a920102 --- /dev/null +++ b/tests/notifications.test.ts @@ -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() + .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().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(); + const service = createDiscordNotificationService( + { + botToken: "", + adminUserId: "" + }, + { fetcher } + ); + + await expect(service.sendPairingNotification(request)).resolves.toBe(false); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/pairing-and-rules.test.ts b/tests/pairing-and-rules.test.ts new file mode 100644 index 0000000..f538d0f --- /dev/null +++ b/tests/pairing-and-rules.test.ts @@ -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); + }); +}); diff --git a/tests/pairing-auth-liveness.test.ts b/tests/pairing-auth-liveness.test.ts new file mode 100644 index 0000000..8672f39 --- /dev/null +++ b/tests/pairing-auth-liveness.test.ts @@ -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, + 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 }; +} diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts new file mode 100644 index 0000000..778177b --- /dev/null +++ b/tests/runtime-flow.test.ts @@ -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.Client/plugin/crypto/keypair.js"; + +function createMockSocket() { + return { + close: vi.fn() + } as unknown as ClientConnection["ws"]; +} + +function createConnection(identifier: string | null = null): ClientConnection { + return { + identifier, + ws: createMockSocket(), + connectedAt: 1_710_000_000, + isAuthenticated: false + }; +} + +function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore { + const persisted = new Map(initialClients.map((record) => [record.identifier, record])); + + return { + filePath: "/tmp/yonexus-server-test.json", + load: vi.fn(async () => ({ + version: 1, + persistedAt: 1_710_000_000, + clients: new Map(persisted) + })), + save: vi.fn(async (clients: Iterable) => { + 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(); + 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)).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)).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(); + } + }); +}); diff --git a/tests/state-recovery.test.ts b/tests/state-recovery.test.ts new file mode 100644 index 0000000..bfa03d7 --- /dev/null +++ b/tests/state-recovery.test.ts @@ -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 { + 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"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..658f108 --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7fed22e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node" + } +});