Compare commits
17 Commits
d8290c0aa7
...
4f4c6bf993
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f4c6bf993 | |||
| 35d787be04 | |||
| b8008d9302 | |||
| 25e1867adf | |||
| 988170dcf6 | |||
| 4f20ec3fd7 | |||
| 075fcb7974 | |||
| ba007ebd59 | |||
| 83f6195c1f | |||
| a05b226056 | |||
| cd09fe6043 | |||
| f7c7531385 | |||
| b44a4cae66 | |||
| c5287fa474 | |||
| bc1a002a8c | |||
| 3ec57ce199 | |||
| ac128d3827 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
146
README.md
146
README.md
@@ -0,0 +1,146 @@
|
|||||||
|
# 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: **scaffold + core runtime MVP**
|
||||||
|
|
||||||
|
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
|
||||||
|
- notification service stub/mock for pairing DM flow
|
||||||
|
|
||||||
|
Still pending before production use:
|
||||||
|
|
||||||
|
- automated Server unit/integration tests
|
||||||
|
- real Discord DM transport wiring
|
||||||
|
- operator hardening / troubleshooting docs
|
||||||
|
- broader lifecycle integration with real OpenClaw plugin hooks
|
||||||
|
|
||||||
|
## Install Layout
|
||||||
|
|
||||||
|
This repo expects the shared protocol repo to be available at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
protocol/
|
||||||
|
```
|
||||||
|
|
||||||
|
In the umbrella repo this is managed as a submodule.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Required config shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"followerIdentifiers": ["client-a", "client-b"],
|
||||||
|
"notifyBotToken": "<discord-bot-token>",
|
||||||
|
"adminUserId": "123456789012345678",
|
||||||
|
"listenHost": "0.0.0.0",
|
||||||
|
"listenPort": 8787,
|
||||||
|
"publicWsUrl": "wss://example.com/yonexus"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field notes
|
||||||
|
|
||||||
|
- `followerIdentifiers`: allowlisted client identifiers
|
||||||
|
- `notifyBotToken`: bot token used for pairing notifications
|
||||||
|
- `adminUserId`: Discord user that receives pairing DMs
|
||||||
|
- `listenHost`: optional bind host, defaults to local runtime handling
|
||||||
|
- `listenPort`: required WebSocket listen port
|
||||||
|
- `publicWsUrl`: optional public endpoint to document/share with clients
|
||||||
|
|
||||||
|
## Runtime Overview
|
||||||
|
|
||||||
|
Startup flow:
|
||||||
|
|
||||||
|
1. validate config
|
||||||
|
2. load persisted trust records
|
||||||
|
3. ensure allowlisted identifiers have base records
|
||||||
|
4. start WebSocket transport
|
||||||
|
5. start liveness sweep timer
|
||||||
|
|
||||||
|
Connection flow:
|
||||||
|
|
||||||
|
1. unauthenticated socket connects
|
||||||
|
2. client sends `hello`
|
||||||
|
3. server decides `pair_required`, `waiting_pair_confirm`, or `auth_required`
|
||||||
|
4. if needed, server creates a pending pairing request and notifies admin out-of-band
|
||||||
|
5. client confirms pairing or authenticates with signed proof
|
||||||
|
6. authenticated connection is promoted to the active session for that identifier
|
||||||
|
|
||||||
|
## Public API Surface
|
||||||
|
|
||||||
|
Exported runtime helpers currently include:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
sendMessageToClient(identifier: string, message: string): Promise<boolean>
|
||||||
|
sendRuleMessageToClient(identifier: string, ruleIdentifier: string, content: string): Promise<boolean>
|
||||||
|
registerRule(rule: string, processor: (message: string) => unknown): void
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `builtin` is reserved and cannot be registered
|
||||||
|
- server-side dispatch expects rewritten client-originated messages in the form:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${rule_identifier}::${sender_identifier}::${message_content}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
Durable state is stored as JSON and includes at least:
|
||||||
|
|
||||||
|
- identifier
|
||||||
|
- pairing status
|
||||||
|
- public key
|
||||||
|
- secret
|
||||||
|
- pairing metadata
|
||||||
|
- heartbeat / auth timestamps
|
||||||
|
- last known liveness status
|
||||||
|
|
||||||
|
Rolling nonce and handshake windows are intentionally rebuilt on restart in v1.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Install dependencies and run type checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
Current known limitations:
|
||||||
|
|
||||||
|
- pairing DM sending is still a stub/mock abstraction
|
||||||
|
- no offline message queueing
|
||||||
|
- no multi-server topology
|
||||||
|
- no management UI
|
||||||
|
- no server-side unit/integration test suite yet
|
||||||
|
|
||||||
|
## Related Repos
|
||||||
|
|
||||||
|
- Umbrella: `../`
|
||||||
|
- Shared protocol: `../Yonexus.Protocol`
|
||||||
|
- Client plugin: `../Yonexus.Client`
|
||||||
|
|||||||
1318
package-lock.json
generated
Normal file
1318
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "yonexus-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Yonexus.Server OpenClaw plugin scaffold",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/plugin/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"plugin",
|
||||||
|
"scripts",
|
||||||
|
"protocol",
|
||||||
|
"README.md",
|
||||||
|
"PLAN.md",
|
||||||
|
"SCAFFOLD.md",
|
||||||
|
"STRUCTURE.md",
|
||||||
|
"TASKS.md"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"check": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.5.2",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vitest": "^4.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
plugin/commands/.gitkeep
Normal file
0
plugin/commands/.gitkeep
Normal file
0
plugin/core/.gitkeep
Normal file
0
plugin/core/.gitkeep
Normal file
104
plugin/core/config.ts
Normal file
104
plugin/core/config.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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 Number.isInteger(value) && value >= 1 && value <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidWsUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "ws:" || url.protocol === "wss:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig {
|
||||||
|
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
const rawIdentifiers = source.followerIdentifiers;
|
||||||
|
const followerIdentifiers = Array.isArray(rawIdentifiers)
|
||||||
|
? rawIdentifiers
|
||||||
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!Array.isArray(rawIdentifiers) || 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 notifyBotToken = source.notifyBotToken;
|
||||||
|
if (!isNonEmptyString(notifyBotToken)) {
|
||||||
|
issues.push("notifyBotToken is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUserId = source.adminUserId;
|
||||||
|
if (!isNonEmptyString(adminUserId)) {
|
||||||
|
issues.push("adminUserId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const listenPort = source.listenPort;
|
||||||
|
if (!isValidPort(listenPort)) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
followerIdentifiers,
|
||||||
|
notifyBotToken: notifyBotToken.trim(),
|
||||||
|
adminUserId: adminUserId.trim(),
|
||||||
|
listenHost,
|
||||||
|
listenPort,
|
||||||
|
publicWsUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
41
plugin/core/logging.ts
Normal file
41
plugin/core/logging.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const DEFAULT_VISIBLE_EDGE = 4;
|
||||||
|
|
||||||
|
export type RedactableValue = string | null | undefined;
|
||||||
|
|
||||||
|
export function redactSecret(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
|
||||||
|
return redactValue(value, { visibleEdge, label: "secret" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactPairingCode(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
|
||||||
|
return redactValue(value, { visibleEdge, label: "pairingCode" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactKey(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
|
||||||
|
return redactValue(value, { visibleEdge, label: "key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactValue(
|
||||||
|
value: RedactableValue,
|
||||||
|
options: { visibleEdge?: number; label?: string } = {}
|
||||||
|
): string {
|
||||||
|
const visibleEdge = options.visibleEdge ?? DEFAULT_VISIBLE_EDGE;
|
||||||
|
const label = options.label ?? "value";
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return `<redacted:${label}:empty>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length <= visibleEdge * 2) {
|
||||||
|
return `<redacted:${label}:${value.length}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, visibleEdge)}…${value.slice(-visibleEdge)} <redacted:${label}:${value.length}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
253
plugin/core/persistence.ts
Normal file
253
plugin/core/persistence.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent nonces used in authentication attempts.
|
||||||
|
* This is a rolling window that may be cleared on restart.
|
||||||
|
*/
|
||||||
|
recentNonces: NonceEntry[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent handshake attempt timestamps.
|
||||||
|
* This is a rolling window that may be cleared on restart.
|
||||||
|
*/
|
||||||
|
recentHandshakeAttempts: number[];
|
||||||
|
|
||||||
|
/** Record creation timestamp (UTC unix seconds) */
|
||||||
|
readonly createdAt: number;
|
||||||
|
|
||||||
|
/** Record last update timestamp (UTC unix seconds) */
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory session state (not persisted)
|
||||||
|
*
|
||||||
|
* Represents an active or pending WebSocket connection.
|
||||||
|
*/
|
||||||
|
export interface ClientSession {
|
||||||
|
/** Client identifier */
|
||||||
|
readonly identifier: string;
|
||||||
|
|
||||||
|
/** WebSocket connection instance */
|
||||||
|
readonly socket: unknown; // Will be typed as WebSocket when implementing transport
|
||||||
|
|
||||||
|
/** Public key presented during hello, before pairing completes */
|
||||||
|
publicKey?: string;
|
||||||
|
|
||||||
|
/** Whether the client is currently authenticated */
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
|
||||||
|
/** Session start timestamp (UTC unix seconds) */
|
||||||
|
readonly connectedAt: number;
|
||||||
|
|
||||||
|
/** Last activity timestamp (UTC unix seconds) */
|
||||||
|
lastActivityAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server registry state
|
||||||
|
*
|
||||||
|
* Contains both persistent and in-memory state for all clients.
|
||||||
|
*/
|
||||||
|
export interface ServerRegistry {
|
||||||
|
/** Persistent client records keyed by identifier */
|
||||||
|
clients: Map<string, ClientRecord>;
|
||||||
|
|
||||||
|
/** Active WebSocket sessions keyed by identifier */
|
||||||
|
sessions: Map<string, ClientSession>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialized form of ClientRecord for JSON persistence
|
||||||
|
*/
|
||||||
|
export interface SerializedClientRecord {
|
||||||
|
identifier: string;
|
||||||
|
publicKey?: string;
|
||||||
|
secret?: string;
|
||||||
|
pairingStatus: PairingStatus;
|
||||||
|
pairingCode?: string;
|
||||||
|
pairingExpiresAt?: number;
|
||||||
|
pairingNotifiedAt?: number;
|
||||||
|
pairingNotifyStatus?: PairingNotifyStatus;
|
||||||
|
status: ClientLivenessStatus;
|
||||||
|
lastHeartbeatAt?: number;
|
||||||
|
lastAuthenticatedAt?: number;
|
||||||
|
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,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize a client record and initialize rolling windows
|
||||||
|
*/
|
||||||
|
export function deserializeClientRecord(
|
||||||
|
serialized: SerializedClientRecord
|
||||||
|
): ClientRecord {
|
||||||
|
return {
|
||||||
|
...serialized,
|
||||||
|
recentNonces: [], // Rolling windows cleared on restart
|
||||||
|
recentHandshakeAttempts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a client record is in a pairable state
|
||||||
|
*/
|
||||||
|
export function isPairable(record: ClientRecord): boolean {
|
||||||
|
return record.pairingStatus === "unpaired" || record.pairingStatus === "revoked";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a client record has a pending pairing that may have expired
|
||||||
|
*/
|
||||||
|
export function hasPendingPairing(record: ClientRecord): boolean {
|
||||||
|
return record.pairingStatus === "pending" && record.pairingCode !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a pending pairing has expired
|
||||||
|
*/
|
||||||
|
export function isPairingExpired(record: ClientRecord, now: number = Date.now() / 1000): boolean {
|
||||||
|
if (!hasPendingPairing(record) || record.pairingExpiresAt === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return now > record.pairingExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a client is ready for authentication (has secret and is paired)
|
||||||
|
*/
|
||||||
|
export function canAuthenticate(record: ClientRecord): boolean {
|
||||||
|
return record.pairingStatus === "paired" && record.secret !== undefined && record.publicKey !== undefined;
|
||||||
|
}
|
||||||
89
plugin/core/rules.ts
Normal file
89
plugin/core/rules.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
BUILTIN_RULE,
|
||||||
|
CodecError,
|
||||||
|
parseRewrittenRuleMessage
|
||||||
|
} from "../../../Yonexus.Protocol/src/index.js";
|
||||||
|
|
||||||
|
export type ServerRuleProcessor = (message: string) => unknown;
|
||||||
|
|
||||||
|
export class ServerRuleRegistryError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ServerRuleRegistryError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerRuleRegistry {
|
||||||
|
readonly size: number;
|
||||||
|
registerRule(rule: string, processor: ServerRuleProcessor): void;
|
||||||
|
hasRule(rule: string): boolean;
|
||||||
|
dispatch(raw: string): boolean;
|
||||||
|
getRules(): readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YonexusServerRuleRegistry implements ServerRuleRegistry {
|
||||||
|
private readonly rules = new Map<string, ServerRuleProcessor>();
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.rules.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerRule(rule: string, processor: ServerRuleProcessor): void {
|
||||||
|
const normalizedRule = this.normalizeRule(rule);
|
||||||
|
if (this.rules.has(normalizedRule)) {
|
||||||
|
throw new ServerRuleRegistryError(
|
||||||
|
`Rule '${normalizedRule}' is already registered`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rules.set(normalizedRule, processor);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRule(rule: string): boolean {
|
||||||
|
return this.rules.has(rule.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(raw: string): boolean {
|
||||||
|
const parsed = parseRewrittenRuleMessage(raw);
|
||||||
|
const processor = this.rules.get(parsed.ruleIdentifier);
|
||||||
|
if (!processor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
processor(raw);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRules(): readonly string[] {
|
||||||
|
return [...this.rules.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRule(rule: string): string {
|
||||||
|
const normalizedRule = rule.trim();
|
||||||
|
if (!normalizedRule) {
|
||||||
|
throw new ServerRuleRegistryError("Rule identifier must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedRule === BUILTIN_RULE) {
|
||||||
|
throw new ServerRuleRegistryError(
|
||||||
|
`Rule identifier '${BUILTIN_RULE}' is reserved`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
parseRewrittenRuleMessage(`${normalizedRule}::sender::probe`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CodecError) {
|
||||||
|
throw new ServerRuleRegistryError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedRule;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerRuleRegistry(): ServerRuleRegistry {
|
||||||
|
return new YonexusServerRuleRegistry();
|
||||||
|
}
|
||||||
964
plugin/core/runtime.ts
Normal file
964
plugin/core/runtime.ts
Normal file
@@ -0,0 +1,964 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export interface YonexusServerRuntimeOptions {
|
||||||
|
config: YonexusServerConfig;
|
||||||
|
store: YonexusServerStore;
|
||||||
|
transport: ServerTransport;
|
||||||
|
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 = createDiscordNotificationService({
|
||||||
|
botToken: options.config.notifyBotToken,
|
||||||
|
adminUserId: options.config.adminUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): ServerLifecycleState {
|
||||||
|
return {
|
||||||
|
isStarted: this.started,
|
||||||
|
registry: this.registry
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persisted = await this.options.store.load();
|
||||||
|
for (const record of persisted.clients.values()) {
|
||||||
|
this.registry.clients.set(record.identifier, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const identifier of this.options.config.followerIdentifiers) {
|
||||||
|
if (!this.registry.clients.has(identifier)) {
|
||||||
|
this.registry.clients.set(identifier, createClientRecord(identifier));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.options.transport.start();
|
||||||
|
this.startSweepTimer();
|
||||||
|
this.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopSweepTimer();
|
||||||
|
await this.persist();
|
||||||
|
this.registry.sessions.clear();
|
||||||
|
await this.options.transport.stop();
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(identifier: string | null): void {
|
||||||
|
if (!identifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.registry.sessions.get(identifier);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this.registry.clients.get(identifier);
|
||||||
|
if (record) {
|
||||||
|
record.status = "offline";
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registry.sessions.delete(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleMessage(connection: ClientConnection, raw: string): Promise<void> {
|
||||||
|
if (!isBuiltinMessage(raw)) {
|
||||||
|
// Handle rule message - rewrite and dispatch
|
||||||
|
await this.handleRuleMessage(connection, raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let envelope: BuiltinEnvelope;
|
||||||
|
try {
|
||||||
|
envelope = decodeBuiltin(raw);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof CodecError ? error.message : "Invalid builtin message";
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{ code: "MALFORMED_MESSAGE", message },
|
||||||
|
{ timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.type === "hello") {
|
||||||
|
await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.type === "pair_confirm") {
|
||||||
|
await this.handlePairConfirm(
|
||||||
|
connection,
|
||||||
|
envelope as BuiltinEnvelope<"pair_confirm", PairConfirmPayload>
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.type === "auth_request") {
|
||||||
|
await this.handleAuthRequest(
|
||||||
|
connection,
|
||||||
|
envelope as BuiltinEnvelope<"auth_request", AuthRequestPayload>
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.type === "heartbeat") {
|
||||||
|
await this.handleHeartbeat(
|
||||||
|
connection,
|
||||||
|
envelope as BuiltinEnvelope<"heartbeat", HeartbeatPayload>
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{
|
||||||
|
code: "MALFORMED_MESSAGE",
|
||||||
|
message: `Unsupported builtin type: ${String(envelope.type)}`
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleHello(
|
||||||
|
connection: ClientConnection,
|
||||||
|
envelope: BuiltinEnvelope<"hello", HelloPayload>
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = envelope.payload;
|
||||||
|
if (!payload) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(buildError({ code: "MALFORMED_MESSAGE", message: "hello payload is required" }, { timestamp: this.now() }))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const helloIdentifier = payload.identifier?.trim();
|
||||||
|
if (!helloIdentifier || !this.options.config.followerIdentifiers.includes(helloIdentifier)) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(buildError({ code: "IDENTIFIER_NOT_ALLOWED", message: "identifier is not allowed" }, { timestamp: this.now() }))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.protocolVersion !== YONEXUS_PROTOCOL_VERSION) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{
|
||||||
|
code: "UNSUPPORTED_PROTOCOL_VERSION",
|
||||||
|
message: `Unsupported protocol version: ${payload.protocolVersion}`
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
connection.ws.close(1002, "Unsupported protocol version");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this.ensureClientRecord(helloIdentifier);
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
|
||||||
|
this.options.transport.assignIdentifierToTemp(connection.ws, helloIdentifier);
|
||||||
|
this.registry.sessions.set(helloIdentifier, {
|
||||||
|
identifier: helloIdentifier,
|
||||||
|
socket: connection.ws,
|
||||||
|
isAuthenticated: false,
|
||||||
|
connectedAt: connection.connectedAt,
|
||||||
|
lastActivityAt: this.now(),
|
||||||
|
publicKey: payload.publicKey?.trim() || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextAction = this.determineNextAction(record);
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
{ ...connection, identifier: helloIdentifier },
|
||||||
|
encodeBuiltin(
|
||||||
|
buildHelloAck(
|
||||||
|
{
|
||||||
|
identifier: helloIdentifier,
|
||||||
|
nextAction
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextAction === "pair_required" || nextAction === "waiting_pair_confirm") {
|
||||||
|
await this.beginPairing({
|
||||||
|
record,
|
||||||
|
connection: { ...connection, identifier: helloIdentifier },
|
||||||
|
requestId: envelope.requestId,
|
||||||
|
reusePending: nextAction === "waiting_pair_confirm"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePairConfirm(
|
||||||
|
connection: ClientConnection,
|
||||||
|
envelope: BuiltinEnvelope<"pair_confirm", PairConfirmPayload>
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = envelope.payload;
|
||||||
|
if (!payload) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{ code: "MALFORMED_MESSAGE", message: "pair_confirm payload is required" },
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = payload.identifier?.trim();
|
||||||
|
if (!identifier || !this.options.config.followerIdentifiers.includes(identifier)) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildPairFailed(
|
||||||
|
{
|
||||||
|
identifier: identifier || "unknown",
|
||||||
|
reason: "identifier_not_allowed"
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this.ensureClientRecord(identifier);
|
||||||
|
const submittedCode = payload.pairingCode?.trim();
|
||||||
|
if (!submittedCode) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{ code: "MALFORMED_MESSAGE", message: "pairingCode is required" },
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.pairingService.confirmPairing(record, submittedCode);
|
||||||
|
if (!result.success || !result.secret || !result.pairedAt) {
|
||||||
|
const reason = result.reason === "not_pending" ? "internal_error" : result.reason ?? "internal_error";
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildPairFailed(
|
||||||
|
{
|
||||||
|
identifier,
|
||||||
|
reason
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.persist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.identifier !== identifier) {
|
||||||
|
this.options.transport.assignIdentifierToTemp(connection.ws, identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.registry.sessions.get(identifier);
|
||||||
|
record.publicKey = session?.publicKey ?? record.publicKey;
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
{ ...connection, identifier },
|
||||||
|
encodeBuiltin(
|
||||||
|
buildPairSuccess(
|
||||||
|
{
|
||||||
|
identifier,
|
||||||
|
secret: result.secret,
|
||||||
|
pairedAt: result.pairedAt
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAuthRequest(
|
||||||
|
connection: ClientConnection,
|
||||||
|
envelope: BuiltinEnvelope<"auth_request", AuthRequestPayload>
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = envelope.payload;
|
||||||
|
if (!payload) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{ code: "MALFORMED_MESSAGE", message: "auth_request payload is required" },
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = payload.identifier?.trim();
|
||||||
|
if (!identifier || !this.options.config.followerIdentifiers.includes(identifier)) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildAuthFailed(
|
||||||
|
{
|
||||||
|
identifier: identifier || "unknown",
|
||||||
|
reason: "unknown_identifier"
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this.ensureClientRecord(identifier);
|
||||||
|
const session = this.registry.sessions.get(identifier);
|
||||||
|
if (!session || !canAuthenticate(record) || !record.secret || !record.publicKey) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildAuthFailed(
|
||||||
|
{
|
||||||
|
identifier,
|
||||||
|
reason: "not_paired"
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = this.now();
|
||||||
|
record.recentHandshakeAttempts = record.recentHandshakeAttempts.filter(
|
||||||
|
(timestamp) => now - timestamp < AUTH_ATTEMPT_WINDOW_SECONDS
|
||||||
|
);
|
||||||
|
record.recentHandshakeAttempts.push(now);
|
||||||
|
|
||||||
|
if (record.recentHandshakeAttempts.length > AUTH_MAX_ATTEMPTS_PER_WINDOW) {
|
||||||
|
await this.triggerRePairRequired(connection, record, envelope.requestId, "rate_limited");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidAuthNonce(payload.nonce)) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildAuthFailed(
|
||||||
|
{
|
||||||
|
identifier,
|
||||||
|
reason: "invalid_signature"
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: now }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshness = isTimestampFresh(payload.proofTimestamp, now);
|
||||||
|
if (!freshness.ok) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildAuthFailed(
|
||||||
|
{
|
||||||
|
identifier,
|
||||||
|
reason: freshness.reason
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: now }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNonceCollision = record.recentNonces.some((entry) => entry.nonce === payload.nonce);
|
||||||
|
if (hasNonceCollision) {
|
||||||
|
await this.triggerRePairRequired(connection, record, envelope.requestId, "nonce_collision");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = payload.publicKey?.trim() || session.publicKey || record.publicKey;
|
||||||
|
if (!publicKey || publicKey !== record.publicKey) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildAuthFailed(
|
||||||
|
{
|
||||||
|
identifier,
|
||||||
|
reason: "invalid_signature"
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: now }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidSignature = await verifySignature(
|
||||||
|
publicKey,
|
||||||
|
extractAuthRequestSigningInput(payload, record.secret),
|
||||||
|
payload.signature
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValidSignature) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildAuthFailed(
|
||||||
|
{
|
||||||
|
identifier,
|
||||||
|
reason: "invalid_signature"
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: now }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
record.recentNonces = [...record.recentNonces, { nonce: payload.nonce, timestamp: now }].slice(
|
||||||
|
-AUTH_RECENT_NONCE_WINDOW_SIZE
|
||||||
|
);
|
||||||
|
record.lastAuthenticatedAt = now;
|
||||||
|
record.lastHeartbeatAt = now;
|
||||||
|
record.status = "online";
|
||||||
|
record.updatedAt = now;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
session.isAuthenticated = true;
|
||||||
|
session.lastActivityAt = now;
|
||||||
|
session.publicKey = publicKey;
|
||||||
|
}
|
||||||
|
this.options.transport.promoteToAuthenticated(identifier, connection.ws);
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
{ ...connection, identifier },
|
||||||
|
encodeBuiltin(
|
||||||
|
buildAuthSuccess(
|
||||||
|
{
|
||||||
|
identifier,
|
||||||
|
authenticatedAt: now,
|
||||||
|
status: "online"
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: now }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private determineNextAction(record: ClientRecord): "pair_required" | "auth_required" | "waiting_pair_confirm" {
|
||||||
|
if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) {
|
||||||
|
return "waiting_pair_confirm";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canAuthenticate(record)) {
|
||||||
|
return "auth_required";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "pair_required";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureClientRecord(identifier: string): ClientRecord {
|
||||||
|
const existing = this.registry.clients.get(identifier);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = createClientRecord(identifier);
|
||||||
|
this.registry.clients.set(identifier, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async beginPairing(options: {
|
||||||
|
record: ClientRecord;
|
||||||
|
connection: ClientConnection;
|
||||||
|
requestId?: string;
|
||||||
|
reusePending?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { record, connection, requestId, reusePending = false } = options;
|
||||||
|
|
||||||
|
const request =
|
||||||
|
reusePending && hasPendingPairing(record) && !isPairingExpired(record, this.now())
|
||||||
|
? {
|
||||||
|
identifier: record.identifier,
|
||||||
|
pairingCode: record.pairingCode ?? "",
|
||||||
|
expiresAt: record.pairingExpiresAt ?? this.now(),
|
||||||
|
ttlSeconds: this.pairingService.getRemainingTtl(record),
|
||||||
|
createdAt: record.updatedAt
|
||||||
|
}
|
||||||
|
: this.pairingService.createPairingRequest(record);
|
||||||
|
|
||||||
|
const notified = reusePending
|
||||||
|
? record.pairingNotifyStatus === "sent"
|
||||||
|
: await this.notificationService.sendPairingNotification(request);
|
||||||
|
|
||||||
|
if (notified) {
|
||||||
|
this.pairingService.markNotificationSent(record);
|
||||||
|
} else {
|
||||||
|
this.pairingService.markNotificationFailed(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildPairRequest(
|
||||||
|
{
|
||||||
|
identifier: record.identifier,
|
||||||
|
expiresAt: request.expiresAt,
|
||||||
|
ttlSeconds: this.pairingService.getRemainingTtl(record),
|
||||||
|
adminNotification: notified ? "sent" : "failed",
|
||||||
|
codeDelivery: "out_of_band"
|
||||||
|
},
|
||||||
|
{ requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!notified) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildPairFailed(
|
||||||
|
{
|
||||||
|
identifier: record.identifier,
|
||||||
|
reason: "admin_notification_failed"
|
||||||
|
},
|
||||||
|
{ requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.pairingService.clearPairingState(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleHeartbeat(
|
||||||
|
connection: ClientConnection,
|
||||||
|
envelope: BuiltinEnvelope<"heartbeat", HeartbeatPayload>
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = envelope.payload;
|
||||||
|
if (!payload) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{ code: "MALFORMED_MESSAGE", message: "heartbeat payload is required" },
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = payload.identifier?.trim();
|
||||||
|
if (!identifier || !this.options.config.followerIdentifiers.includes(identifier)) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{ code: "IDENTIFIER_NOT_ALLOWED", message: "identifier is not allowed" },
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.registry.sessions.get(identifier);
|
||||||
|
if (!session || !session.isAuthenticated) {
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{ code: "AUTH_FAILED", message: "heartbeat requires authentication" },
|
||||||
|
{ requestId: envelope.requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this.ensureClientRecord(identifier);
|
||||||
|
const now = this.now();
|
||||||
|
record.lastHeartbeatAt = now;
|
||||||
|
record.status = "online";
|
||||||
|
record.updatedAt = now;
|
||||||
|
session.lastActivityAt = now;
|
||||||
|
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
{ ...connection, identifier },
|
||||||
|
encodeBuiltin(
|
||||||
|
buildHeartbeatAck(
|
||||||
|
{
|
||||||
|
identifier,
|
||||||
|
status: record.status
|
||||||
|
},
|
||||||
|
{ requestId: envelope.requestId, timestamp: now }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSweepTimer(): void {
|
||||||
|
this.stopSweepTimer();
|
||||||
|
this.sweepTimer = setInterval(() => {
|
||||||
|
void this.runLivenessSweep();
|
||||||
|
}, this.sweepIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSweepTimer(): void {
|
||||||
|
if (!this.sweepTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(this.sweepTimer);
|
||||||
|
this.sweepTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runLivenessSweep(): Promise<void> {
|
||||||
|
const now = this.now();
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
for (const record of this.registry.clients.values()) {
|
||||||
|
const nextStatus = this.getLivenessStatus(record, now);
|
||||||
|
if (!nextStatus || nextStatus === record.status) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
record.status = nextStatus;
|
||||||
|
record.updatedAt = now;
|
||||||
|
hasChanges = true;
|
||||||
|
|
||||||
|
if (nextStatus === "unstable") {
|
||||||
|
this.options.transport.send(
|
||||||
|
record.identifier,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildStatusUpdate(
|
||||||
|
{
|
||||||
|
identifier: record.identifier,
|
||||||
|
status: "unstable",
|
||||||
|
reason: "heartbeat_timeout_7m"
|
||||||
|
},
|
||||||
|
{ timestamp: now }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStatus === "offline") {
|
||||||
|
this.options.transport.send(
|
||||||
|
record.identifier,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildDisconnectNotice(
|
||||||
|
{
|
||||||
|
identifier: record.identifier,
|
||||||
|
reason: "heartbeat_timeout_11m"
|
||||||
|
},
|
||||||
|
{ timestamp: now }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.options.transport.closeConnection(record.identifier, 1001, "Heartbeat timeout");
|
||||||
|
this.registry.sessions.delete(record.identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLivenessStatus(
|
||||||
|
record: ClientRecord,
|
||||||
|
now: number
|
||||||
|
): "online" | "unstable" | "offline" | null {
|
||||||
|
const session = this.registry.sessions.get(record.identifier);
|
||||||
|
if (!session || !session.isAuthenticated || !record.lastHeartbeatAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const silenceSeconds = now - record.lastHeartbeatAt;
|
||||||
|
if (silenceSeconds >= 11 * 60) {
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (silenceSeconds >= 7 * 60) {
|
||||||
|
return "unstable";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async triggerRePairRequired(
|
||||||
|
connection: ClientConnection,
|
||||||
|
record: ClientRecord,
|
||||||
|
requestId: string | undefined,
|
||||||
|
reason: "nonce_collision" | "rate_limited"
|
||||||
|
): Promise<void> {
|
||||||
|
record.secret = undefined;
|
||||||
|
record.pairingStatus = "revoked";
|
||||||
|
record.pairingCode = undefined;
|
||||||
|
record.pairingExpiresAt = undefined;
|
||||||
|
record.pairingNotifyStatus = undefined;
|
||||||
|
record.recentNonces = [];
|
||||||
|
record.recentHandshakeAttempts = [];
|
||||||
|
record.status = "offline";
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildRePairRequired(
|
||||||
|
{
|
||||||
|
identifier: record.identifier,
|
||||||
|
reason
|
||||||
|
},
|
||||||
|
{ requestId, timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(): Promise<void> {
|
||||||
|
await this.options.store.save(this.registry.clients.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a rule message to a specific client.
|
||||||
|
*
|
||||||
|
* @param identifier - The target client identifier
|
||||||
|
* @param message - The complete rule message with identifier and content
|
||||||
|
* @returns True if message was sent, false if client not connected/authenticated
|
||||||
|
*/
|
||||||
|
sendMessageToClient(identifier: string, message: string): boolean {
|
||||||
|
const session = this.registry.sessions.get(identifier);
|
||||||
|
if (!session || !session.isAuthenticated) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the message is a properly formatted rule message
|
||||||
|
try {
|
||||||
|
// Quick check: must not be a builtin message and must have :: delimiter
|
||||||
|
if (message.startsWith("builtin::")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const delimiterIndex = message.indexOf("::");
|
||||||
|
if (delimiterIndex === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
parseRuleMessage(message);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.options.transport.send(identifier, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a rule message to a specific client using separate rule identifier and content.
|
||||||
|
*
|
||||||
|
* @param identifier - The target client identifier
|
||||||
|
* @param ruleIdentifier - The rule identifier
|
||||||
|
* @param content - The message content
|
||||||
|
* @returns True if message was sent, false if client not connected/authenticated or invalid format
|
||||||
|
*/
|
||||||
|
sendRuleMessageToClient(identifier: string, ruleIdentifier: string, content: string): boolean {
|
||||||
|
const session = this.registry.sessions.get(identifier);
|
||||||
|
if (!session || !session.isAuthenticated) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encoded = encodeRuleMessage(ruleIdentifier, content);
|
||||||
|
return this.options.transport.send(identifier, encoded);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming rule message from a client.
|
||||||
|
* Rewrites the message to include sender identifier before dispatch.
|
||||||
|
*
|
||||||
|
* @param connection - The client connection
|
||||||
|
* @param raw - The raw rule message
|
||||||
|
*/
|
||||||
|
private async handleRuleMessage(connection: ClientConnection, raw: string): Promise<void> {
|
||||||
|
// Get sender identifier from connection or session
|
||||||
|
let senderIdentifier = connection.identifier;
|
||||||
|
if (!senderIdentifier) {
|
||||||
|
// Try to find identifier from WebSocket
|
||||||
|
for (const [id, session] of this.registry.sessions.entries()) {
|
||||||
|
if (session.socket === connection.ws) {
|
||||||
|
senderIdentifier = id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!senderIdentifier) {
|
||||||
|
// Cannot determine sender - close connection
|
||||||
|
connection.ws.close(1008, "Cannot identify sender");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.registry.sessions.get(senderIdentifier);
|
||||||
|
if (!session || !session.isAuthenticated) {
|
||||||
|
// Only accept rule messages from authenticated clients
|
||||||
|
connection.ws.close(1008, "Not authenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseRuleMessage(raw);
|
||||||
|
const rewritten = `${parsed.ruleIdentifier}::${senderIdentifier}::${parsed.content}`;
|
||||||
|
|
||||||
|
// TODO: Dispatch to registered rules via rule registry
|
||||||
|
// For now, just log the rewritten message
|
||||||
|
// this.ruleRegistry.dispatch(rewritten);
|
||||||
|
|
||||||
|
// Update last activity
|
||||||
|
session.lastActivityAt = this.now();
|
||||||
|
|
||||||
|
// Future: dispatch to rule registry
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
void rewritten;
|
||||||
|
} catch (error) {
|
||||||
|
// Malformed rule message
|
||||||
|
this.options.transport.sendToConnection(
|
||||||
|
connection,
|
||||||
|
encodeBuiltin(
|
||||||
|
buildError(
|
||||||
|
{
|
||||||
|
code: "MALFORMED_MESSAGE",
|
||||||
|
message: safeErrorMessage(error) || "Invalid rule message format"
|
||||||
|
},
|
||||||
|
{ timestamp: this.now() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createYonexusServerRuntime(
|
||||||
|
options: YonexusServerRuntimeOptions
|
||||||
|
): YonexusServerRuntime {
|
||||||
|
return new YonexusServerRuntime(options);
|
||||||
|
}
|
||||||
181
plugin/core/store.ts
Normal file
181
plugin/core/store.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
deserializeClientRecord,
|
||||||
|
serializeClientRecord,
|
||||||
|
type ClientRecord,
|
||||||
|
type SerializedClientRecord,
|
||||||
|
type ServerPersistenceData
|
||||||
|
} from "./persistence.js";
|
||||||
|
|
||||||
|
export const SERVER_PERSISTENCE_VERSION = 1;
|
||||||
|
|
||||||
|
export class YonexusServerStoreError extends Error {
|
||||||
|
override readonly cause?: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, cause?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = "YonexusServerStoreError";
|
||||||
|
this.cause = cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YonexusServerStoreCorruptionError extends YonexusServerStoreError {
|
||||||
|
constructor(message: string, cause?: unknown) {
|
||||||
|
super(message, cause);
|
||||||
|
this.name = "YonexusServerStoreCorruptionError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerStoreLoadResult {
|
||||||
|
readonly version: number;
|
||||||
|
readonly persistedAt?: number;
|
||||||
|
readonly clients: Map<string, ClientRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YonexusServerStore {
|
||||||
|
readonly filePath: string;
|
||||||
|
load(): Promise<ServerStoreLoadResult>;
|
||||||
|
save(clients: Iterable<ClientRecord>): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createYonexusServerStore(filePath: string): YonexusServerStore {
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
load: async () => loadServerStore(filePath),
|
||||||
|
save: async (clients) => saveServerStore(filePath, clients)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadServerStore(filePath: string): Promise<ServerStoreLoadResult> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as ServerPersistenceData;
|
||||||
|
assertPersistenceDataShape(parsed, filePath);
|
||||||
|
|
||||||
|
const clients = new Map<string, ClientRecord>();
|
||||||
|
for (const serialized of parsed.clients) {
|
||||||
|
assertSerializedClientRecordShape(serialized, filePath);
|
||||||
|
clients.set(serialized.identifier, deserializeClientRecord(serialized));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: parsed.version,
|
||||||
|
persistedAt: parsed.persistedAt,
|
||||||
|
clients
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isFileNotFoundError(error)) {
|
||||||
|
return {
|
||||||
|
version: SERVER_PERSISTENCE_VERSION,
|
||||||
|
clients: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof YonexusServerStoreError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Failed to load Yonexus.Server persistence file: ${filePath}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveServerStore(
|
||||||
|
filePath: string,
|
||||||
|
clients: Iterable<ClientRecord>
|
||||||
|
): Promise<void> {
|
||||||
|
const payload: ServerPersistenceData = {
|
||||||
|
version: SERVER_PERSISTENCE_VERSION,
|
||||||
|
persistedAt: Math.floor(Date.now() / 1000),
|
||||||
|
clients: Array.from(clients, (record) => serializeClientRecord(record))
|
||||||
|
};
|
||||||
|
|
||||||
|
const tempPath = `${filePath}.tmp`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||||
|
await rename(tempPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new YonexusServerStoreError(
|
||||||
|
`Failed to save Yonexus.Server persistence file: ${filePath}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPersistenceDataShape(
|
||||||
|
value: unknown,
|
||||||
|
filePath: string
|
||||||
|
): asserts value is ServerPersistenceData {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file is not a JSON object: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<ServerPersistenceData>;
|
||||||
|
if (candidate.version !== SERVER_PERSISTENCE_VERSION) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Unsupported persistence version in ${filePath}: ${String(candidate.version)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(candidate.clients)) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file has invalid clients array: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
candidate.persistedAt !== undefined &&
|
||||||
|
(!Number.isInteger(candidate.persistedAt) || candidate.persistedAt < 0)
|
||||||
|
) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file has invalid persistedAt value: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSerializedClientRecordShape(
|
||||||
|
value: unknown,
|
||||||
|
filePath: string
|
||||||
|
): asserts value is SerializedClientRecord {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file contains a non-object client record: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<SerializedClientRecord>;
|
||||||
|
if (typeof candidate.identifier !== "string" || candidate.identifier.trim().length === 0) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file contains a client record with invalid identifier: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof candidate.pairingStatus !== "string" || typeof candidate.status !== "string") {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file contains a client record with invalid state fields: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.updatedAt)) {
|
||||||
|
throw new YonexusServerStoreCorruptionError(
|
||||||
|
`Persistence file contains a client record with invalid timestamps: ${filePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"code" in error &&
|
||||||
|
(error as { code?: unknown }).code === "ENOENT"
|
||||||
|
);
|
||||||
|
}
|
||||||
283
plugin/core/transport.ts
Normal file
283
plugin/core/transport.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { WebSocketServer, WebSocket, RawData } from "ws";
|
||||||
|
import type { YonexusServerConfig } from "./config.js";
|
||||||
|
import { safeErrorMessage } from "./logging.js";
|
||||||
|
|
||||||
|
export interface ClientConnection {
|
||||||
|
readonly identifier: string | null;
|
||||||
|
readonly ws: WebSocket;
|
||||||
|
readonly connectedAt: number;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary connection tracking before authentication.
|
||||||
|
* Connections remain in this state until successfully authenticated.
|
||||||
|
*/
|
||||||
|
interface TempConnection {
|
||||||
|
readonly ws: WebSocket;
|
||||||
|
readonly connectedAt: number;
|
||||||
|
/** The identifier claimed during hello, if any */
|
||||||
|
assignedIdentifier: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerTransport {
|
||||||
|
readonly isRunning: boolean;
|
||||||
|
readonly connections: ReadonlyMap<string, ClientConnection>;
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
send(identifier: string, message: string): boolean;
|
||||||
|
sendToConnection(connection: ClientConnection, message: string): boolean;
|
||||||
|
broadcast(message: string): void;
|
||||||
|
closeConnection(identifier: string, code?: number, reason?: string): boolean;
|
||||||
|
/**
|
||||||
|
* Promote a temp connection to authenticated status.
|
||||||
|
* This implements the single-identifier-single-active-connection policy:
|
||||||
|
* - If another authenticated connection exists for this identifier, it is closed
|
||||||
|
* - The connection is moved from temp to authenticated registry
|
||||||
|
*/
|
||||||
|
promoteToAuthenticated(identifier: string, ws: WebSocket): boolean;
|
||||||
|
/**
|
||||||
|
* Remove a temp connection without promoting it.
|
||||||
|
* Called when authentication fails or connection closes before auth.
|
||||||
|
*/
|
||||||
|
removeTempConnection(ws: WebSocket): void;
|
||||||
|
/**
|
||||||
|
* Assign an identifier to a temp connection during hello processing.
|
||||||
|
* This does NOT register the connection as authenticated yet.
|
||||||
|
*/
|
||||||
|
assignIdentifierToTemp(ws: WebSocket, identifier: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageHandler = (connection: ClientConnection, message: string) => void;
|
||||||
|
export type ConnectionHandler = (identifier: string | null, ws: WebSocket) => void;
|
||||||
|
export type DisconnectionHandler = (identifier: string | null, code: number, reason: Buffer) => void;
|
||||||
|
|
||||||
|
export interface ServerTransportOptions {
|
||||||
|
config: YonexusServerConfig;
|
||||||
|
onMessage: MessageHandler;
|
||||||
|
onConnect?: ConnectionHandler;
|
||||||
|
onDisconnect?: DisconnectionHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YonexusServerTransport implements ServerTransport {
|
||||||
|
private wss: WebSocketServer | null = null;
|
||||||
|
private _connections = new Map<string, ClientConnection>();
|
||||||
|
private tempConnections = new Map<WebSocket, TempConnection>();
|
||||||
|
private options: ServerTransportOptions;
|
||||||
|
private _isRunning = false;
|
||||||
|
|
||||||
|
constructor(options: ServerTransportOptions) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this._isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
get connections(): ReadonlyMap<string, ClientConnection> {
|
||||||
|
return this._connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this._isRunning) {
|
||||||
|
throw new Error("Server transport is already running");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { listenHost, listenPort } = this.options.config;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.wss = new WebSocketServer({
|
||||||
|
host: listenHost,
|
||||||
|
port: listenPort
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wss.on("error", (error) => {
|
||||||
|
if (!this._isRunning) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wss.on("listening", () => {
|
||||||
|
this._isRunning = true;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wss.on("connection", (ws, req) => {
|
||||||
|
this.handleConnection(ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this._isRunning || !this.wss) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all authenticated connections
|
||||||
|
for (const conn of this._connections.values()) {
|
||||||
|
conn.ws.close(1000, "Server shutting down");
|
||||||
|
}
|
||||||
|
this._connections.clear();
|
||||||
|
|
||||||
|
// Close all temp connections
|
||||||
|
for (const temp of this.tempConnections.values()) {
|
||||||
|
temp.ws.close(1000, "Server shutting down");
|
||||||
|
}
|
||||||
|
this.tempConnections.clear();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.wss!.close(() => {
|
||||||
|
this._isRunning = false;
|
||||||
|
this.wss = null;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(identifier: string, message: string): boolean {
|
||||||
|
const conn = this._connections.get(identifier);
|
||||||
|
if (!conn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sendToConnection(conn, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToConnection(connection: ClientConnection, message: string): boolean {
|
||||||
|
const { ws } = connection;
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(message: string): void {
|
||||||
|
for (const conn of this._connections.values()) {
|
||||||
|
if (conn.isAuthenticated) {
|
||||||
|
this.sendToConnection(conn, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConnection(identifier: string, code = 1000, reason = "Connection closed"): boolean {
|
||||||
|
const conn = this._connections.get(identifier);
|
||||||
|
if (!conn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.ws.close(code, reason);
|
||||||
|
this._connections.delete(identifier);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
promoteToAuthenticated(identifier: string, ws: WebSocket): boolean {
|
||||||
|
// Verify the connection exists in temp connections
|
||||||
|
const tempConn = this.tempConnections.get(ws);
|
||||||
|
if (!tempConn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already have an authenticated connection for this identifier
|
||||||
|
// If so, close it (single-identifier-single-active-connection policy)
|
||||||
|
const existingConn = this._connections.get(identifier);
|
||||||
|
if (existingConn) {
|
||||||
|
existingConn.ws.close(1008, "Connection replaced by new authenticated session");
|
||||||
|
this._connections.delete(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
// Try to get identifier from temp connections first, then authenticated connections
|
||||||
|
let identifier: string | null = null;
|
||||||
|
const tempData = this.tempConnections.get(ws);
|
||||||
|
if (tempData) {
|
||||||
|
identifier = tempData.assignedIdentifier;
|
||||||
|
}
|
||||||
|
if (!identifier) {
|
||||||
|
for (const [id, conn] of this._connections) {
|
||||||
|
if (conn.ws === ws) {
|
||||||
|
identifier = id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = identifier ? this._connections.get(identifier) ?? tempConn : tempConn;
|
||||||
|
this.options.onMessage(connection, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", (code: number, reason: Buffer) => {
|
||||||
|
this.tempConnections.delete(ws);
|
||||||
|
|
||||||
|
// Find and remove from authenticated connections
|
||||||
|
for (const [id, conn] of this._connections) {
|
||||||
|
if (conn.ws === ws) {
|
||||||
|
this._connections.delete(id);
|
||||||
|
if (this.options.onDisconnect) {
|
||||||
|
this.options.onDisconnect(id, code, reason);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.onDisconnect) {
|
||||||
|
this.options.onDisconnect(null, code, reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (error: Error) => {
|
||||||
|
// Log error but let close handler clean up
|
||||||
|
console.error("[Yonexus.Server] WebSocket error:", safeErrorMessage(error));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.options.onConnect) {
|
||||||
|
this.options.onConnect(null, ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerTransport(options: ServerTransportOptions): ServerTransport {
|
||||||
|
return new YonexusServerTransport(options);
|
||||||
|
}
|
||||||
42
plugin/crypto/utils.ts
Normal file
42
plugin/crypto/utils.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure random pairing code.
|
||||||
|
* Format: XXXX-XXXX-XXXX (12 alphanumeric characters in groups of 4)
|
||||||
|
* Excludes confusing characters: 0, O, 1, I
|
||||||
|
*/
|
||||||
|
export function generatePairingCode(): string {
|
||||||
|
const bytes = randomBytes(8);
|
||||||
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Excludes confusing chars (0, O, 1, I)
|
||||||
|
|
||||||
|
let code = "";
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
code += chars[bytes[i % bytes.length] % chars.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format as XXXX-XXXX-XXXX
|
||||||
|
return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a shared secret for client authentication.
|
||||||
|
* This is issued by the server after successful pairing.
|
||||||
|
* Returns a base64url-encoded 32-byte random string.
|
||||||
|
*/
|
||||||
|
export function generateSecret(): string {
|
||||||
|
return randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a 24-character nonce for authentication.
|
||||||
|
* Uses base64url encoding of 18 random bytes, truncated to 24 chars.
|
||||||
|
*/
|
||||||
|
export function generateNonce(): string {
|
||||||
|
const bytes = randomBytes(18);
|
||||||
|
return bytes.toString("base64url").slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default pairing code TTL in seconds (5 minutes)
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PAIRING_TTL_SECONDS = 300;
|
||||||
0
plugin/hooks/.gitkeep
Normal file
0
plugin/hooks/.gitkeep
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export interface YonexusServerPluginManifest {
|
||||||
|
readonly name: "Yonexus.Server";
|
||||||
|
readonly version: string;
|
||||||
|
readonly description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YonexusServerPluginRuntime {
|
||||||
|
readonly hooks: readonly [];
|
||||||
|
readonly commands: readonly [];
|
||||||
|
readonly tools: readonly [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: YonexusServerPluginManifest = {
|
||||||
|
name: "Yonexus.Server",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Yonexus central hub plugin for cross-instance OpenClaw communication"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createYonexusServerPlugin(): YonexusServerPluginRuntime {
|
||||||
|
return {
|
||||||
|
hooks: [],
|
||||||
|
commands: [],
|
||||||
|
tools: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|||||||
103
plugin/notifications/discord.ts
Normal file
103
plugin/notifications/discord.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from "../core/logging.js";
|
||||||
|
|
||||||
|
export interface DiscordNotificationService {
|
||||||
|
/**
|
||||||
|
* Send a pairing code notification to the admin user.
|
||||||
|
* @returns Whether the notification was sent successfully
|
||||||
|
*/
|
||||||
|
sendPairingNotification(request: PairingRequest): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordNotificationConfig {
|
||||||
|
botToken: string;
|
||||||
|
adminUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Discord notification service.
|
||||||
|
*
|
||||||
|
* Note: This is a framework stub. Full implementation requires:
|
||||||
|
* - Discord bot client integration (e.g., using discord.js)
|
||||||
|
* - DM channel creation/fetching
|
||||||
|
* - Error handling for blocked DMs, invalid tokens, etc.
|
||||||
|
*
|
||||||
|
* For v1, this provides the interface and a mock implementation
|
||||||
|
* that logs to console. Production deployments should replace
|
||||||
|
* this with actual Discord bot integration.
|
||||||
|
*/
|
||||||
|
export function createDiscordNotificationService(
|
||||||
|
config: DiscordNotificationConfig
|
||||||
|
): DiscordNotificationService {
|
||||||
|
return {
|
||||||
|
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
|
||||||
|
const redactedCode = redactPairingCode(request.pairingCode);
|
||||||
|
|
||||||
|
// Log to console (visible in OpenClaw logs)
|
||||||
|
console.log("[Yonexus.Server] Pairing notification (Discord DM stub):", {
|
||||||
|
identifier: request.identifier,
|
||||||
|
pairingCode: redactedCode,
|
||||||
|
expiresAt: request.expiresAt,
|
||||||
|
ttlSeconds: request.ttlSeconds,
|
||||||
|
adminUserId: config.adminUserId
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Replace with actual Discord bot integration
|
||||||
|
// Example with discord.js:
|
||||||
|
// const client = new Client({ intents: [GatewayIntentBits.DirectMessages] });
|
||||||
|
// await client.login(config.botToken);
|
||||||
|
// const user = await client.users.fetch(config.adminUserId);
|
||||||
|
// await user.send(message);
|
||||||
|
|
||||||
|
// For now, return true to allow pairing flow to continue
|
||||||
|
// In production, this should return false if DM fails
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a pairing request as a Discord DM message.
|
||||||
|
*/
|
||||||
|
export function formatPairingMessage(request: PairingRequest): string {
|
||||||
|
const expiresDate = new Date(request.expiresAt * 1000);
|
||||||
|
const expiresStr = expiresDate.toISOString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
"🔐 **Yonexus Pairing Request**",
|
||||||
|
"",
|
||||||
|
`**Identifier:** \`${request.identifier}\``,
|
||||||
|
`**Pairing Code:** \`${request.pairingCode}\``,
|
||||||
|
`**Expires At:** ${expiresStr}`,
|
||||||
|
`**TTL:** ${request.ttlSeconds} seconds`,
|
||||||
|
"",
|
||||||
|
"Please relay this pairing code to the client operator via a trusted out-of-band channel.",
|
||||||
|
"Do not share this code over the Yonexus WebSocket connection."
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock notification service for testing.
|
||||||
|
* Returns success/failure based on configuration.
|
||||||
|
*/
|
||||||
|
export function createMockNotificationService(
|
||||||
|
options: { shouldSucceed?: boolean } = {}
|
||||||
|
): DiscordNotificationService {
|
||||||
|
const shouldSucceed = options.shouldSucceed ?? true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
|
||||||
|
console.log("[Yonexus.Server] Mock pairing notification:");
|
||||||
|
console.log(` Identifier: ${request.identifier}`);
|
||||||
|
console.log(` Pairing Code: ${redactPairingCode(request.pairingCode)}`);
|
||||||
|
console.log(` Success: ${shouldSucceed}`);
|
||||||
|
return shouldSucceed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "Yonexus.Server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Yonexus central hub plugin for cross-instance OpenClaw communication",
|
||||||
|
"entry": "dist/plugin/index.js",
|
||||||
|
"permissions": [],
|
||||||
|
"config": {
|
||||||
|
"followerIdentifiers": [],
|
||||||
|
"notifyBotToken": "",
|
||||||
|
"adminUserId": "",
|
||||||
|
"listenHost": "0.0.0.0",
|
||||||
|
"listenPort": 8787,
|
||||||
|
"publicWsUrl": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
190
plugin/services/pairing.ts
Normal file
190
plugin/services/pairing.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Yonexus Server - Pairing Service
|
||||||
|
*
|
||||||
|
* Manages client pairing flow:
|
||||||
|
* - Creating pairing requests with codes
|
||||||
|
* - Tracking pairing expiration
|
||||||
|
* - Validating pairing confirmations
|
||||||
|
* - Issuing shared secrets after successful pairing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ClientRecord } from "../core/persistence.js";
|
||||||
|
import { generatePairingCode, generateSecret, DEFAULT_PAIRING_TTL_SECONDS } from "../crypto/utils.js";
|
||||||
|
|
||||||
|
export interface PairingRequest {
|
||||||
|
readonly identifier: string;
|
||||||
|
readonly pairingCode: string;
|
||||||
|
readonly expiresAt: number;
|
||||||
|
readonly ttlSeconds: number;
|
||||||
|
readonly createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingResult {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly secret?: string;
|
||||||
|
readonly pairedAt?: number;
|
||||||
|
readonly reason?: PairingFailureReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PairingFailureReason =
|
||||||
|
| "expired"
|
||||||
|
| "invalid_code"
|
||||||
|
| "not_pending"
|
||||||
|
| "internal_error";
|
||||||
|
|
||||||
|
export class PairingService {
|
||||||
|
private readonly now: () => number;
|
||||||
|
|
||||||
|
constructor(options: { now?: () => number } = {}) {
|
||||||
|
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new pairing request for a client.
|
||||||
|
* Updates the client record with pending pairing state.
|
||||||
|
*/
|
||||||
|
createPairingRequest(
|
||||||
|
record: ClientRecord,
|
||||||
|
options: { ttlSeconds?: number } = {}
|
||||||
|
): PairingRequest {
|
||||||
|
const ttlSeconds = options.ttlSeconds ?? DEFAULT_PAIRING_TTL_SECONDS;
|
||||||
|
const now = this.now();
|
||||||
|
const pairingCode = generatePairingCode();
|
||||||
|
|
||||||
|
// Update the client record
|
||||||
|
record.pairingStatus = "pending";
|
||||||
|
record.pairingCode = pairingCode;
|
||||||
|
record.pairingExpiresAt = now + ttlSeconds;
|
||||||
|
record.pairingNotifyStatus = "pending";
|
||||||
|
record.updatedAt = now;
|
||||||
|
|
||||||
|
return {
|
||||||
|
identifier: record.identifier,
|
||||||
|
pairingCode,
|
||||||
|
expiresAt: record.pairingExpiresAt,
|
||||||
|
ttlSeconds,
|
||||||
|
createdAt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a pairing confirmation from a client.
|
||||||
|
* Returns the pairing result and updates the record on success.
|
||||||
|
*/
|
||||||
|
confirmPairing(
|
||||||
|
record: ClientRecord,
|
||||||
|
submittedCode: string
|
||||||
|
): PairingResult {
|
||||||
|
const now = this.now();
|
||||||
|
|
||||||
|
// Check if pairing is pending
|
||||||
|
if (record.pairingStatus !== "pending") {
|
||||||
|
return { success: false, reason: "not_pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pairing has expired
|
||||||
|
if (record.pairingExpiresAt && now > record.pairingExpiresAt) {
|
||||||
|
this.clearPairingState(record);
|
||||||
|
return { success: false, reason: "expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the pairing code
|
||||||
|
if (record.pairingCode !== submittedCode) {
|
||||||
|
return { success: false, reason: "invalid_code" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pairing successful - generate secret and update record
|
||||||
|
const secret = generateSecret();
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.secret = secret;
|
||||||
|
record.pairedAt = now;
|
||||||
|
record.updatedAt = now;
|
||||||
|
|
||||||
|
// Clear pairing-specific fields
|
||||||
|
record.pairingCode = undefined;
|
||||||
|
record.pairingExpiresAt = undefined;
|
||||||
|
record.pairingNotifiedAt = undefined;
|
||||||
|
record.pairingNotifyStatus = undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
secret,
|
||||||
|
pairedAt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a pairing notification as sent.
|
||||||
|
*/
|
||||||
|
markNotificationSent(record: ClientRecord): void {
|
||||||
|
if (record.pairingStatus === "pending") {
|
||||||
|
record.pairingNotifyStatus = "sent";
|
||||||
|
record.pairingNotifiedAt = this.now();
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a pairing notification as failed.
|
||||||
|
*/
|
||||||
|
markNotificationFailed(record: ClientRecord): void {
|
||||||
|
if (record.pairingStatus === "pending") {
|
||||||
|
record.pairingNotifyStatus = "failed";
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear pairing state for a client.
|
||||||
|
* Used when pairing fails or is cancelled.
|
||||||
|
*/
|
||||||
|
clearPairingState(record: ClientRecord): void {
|
||||||
|
record.pairingStatus = record.pairingStatus === "paired" ? "paired" : "unpaired";
|
||||||
|
record.pairingCode = undefined;
|
||||||
|
record.pairingExpiresAt = undefined;
|
||||||
|
record.pairingNotifiedAt = undefined;
|
||||||
|
record.pairingNotifyStatus = undefined;
|
||||||
|
record.updatedAt = this.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke pairing for a client.
|
||||||
|
* Clears secret and returns to unpaired state.
|
||||||
|
*/
|
||||||
|
revokePairing(record: ClientRecord): void {
|
||||||
|
record.pairingStatus = "revoked";
|
||||||
|
record.secret = undefined;
|
||||||
|
record.publicKey = undefined;
|
||||||
|
record.pairedAt = undefined;
|
||||||
|
this.clearPairingState(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a pairing request is expired.
|
||||||
|
*/
|
||||||
|
isExpired(record: ClientRecord): boolean {
|
||||||
|
if (!record.pairingExpiresAt) return false;
|
||||||
|
return this.now() > record.pairingExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining TTL for a pending pairing.
|
||||||
|
* Returns 0 if expired or not pending.
|
||||||
|
*/
|
||||||
|
getRemainingTtl(record: ClientRecord): number {
|
||||||
|
if (record.pairingStatus !== "pending" || !record.pairingExpiresAt) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const remaining = record.pairingExpiresAt - this.now();
|
||||||
|
return Math.max(0, remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a pairing service.
|
||||||
|
*/
|
||||||
|
export function createPairingService(
|
||||||
|
options: { now?: () => number } = {}
|
||||||
|
): PairingService {
|
||||||
|
return new PairingService(options);
|
||||||
|
}
|
||||||
0
plugin/tools/.gitkeep
Normal file
0
plugin/tools/.gitkeep
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/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 <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"));
|
||||||
|
console.log(`Installed ${pluginName} to ${targetDir}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||||
|
console.log(`Removed ${pluginName} from ${targetDir}`);
|
||||||
|
|||||||
0
servers/.gitkeep
Normal file
0
servers/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
102
tests/pairing-and-rules.test.ts
Normal file
102
tests/pairing-and-rules.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createClientRecord } from "../plugin/core/persistence.js";
|
||||||
|
import { createServerRuleRegistry, ServerRuleRegistryError } from "../plugin/core/rules.js";
|
||||||
|
import { createPairingService } from "../plugin/services/pairing.js";
|
||||||
|
|
||||||
|
describe("Yonexus.Server PairingService", () => {
|
||||||
|
it("creates a pending pairing request with ttl metadata", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||||
|
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 180 });
|
||||||
|
|
||||||
|
expect(request.identifier).toBe("client-a");
|
||||||
|
expect(request.ttlSeconds).toBe(180);
|
||||||
|
expect(request.expiresAt).toBe(1_710_000_180);
|
||||||
|
expect(record.pairingStatus).toBe("pending");
|
||||||
|
expect(record.pairingCode).toBe(request.pairingCode);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms a valid pairing and clears pairing-only fields", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_100 });
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 300 });
|
||||||
|
|
||||||
|
const result = pairing.confirmPairing(record, request.pairingCode);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
pairedAt: 1_710_000_100
|
||||||
|
});
|
||||||
|
expect(typeof result.secret).toBe("string");
|
||||||
|
expect(record.pairingStatus).toBe("paired");
|
||||||
|
expect(record.secret).toBe(result.secret);
|
||||||
|
expect(record.pairingCode).toBeUndefined();
|
||||||
|
expect(record.pairingExpiresAt).toBeUndefined();
|
||||||
|
expect(record.pairingNotifyStatus).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects expired and invalid pairing confirmations without dirtying state", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
let now = 1_710_000_000;
|
||||||
|
const pairing = createPairingService({ now: () => now });
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 60 });
|
||||||
|
|
||||||
|
const invalid = pairing.confirmPairing(record, "WRONG-CODE-000");
|
||||||
|
expect(invalid).toEqual({ success: false, reason: "invalid_code" });
|
||||||
|
expect(record.pairingStatus).toBe("pending");
|
||||||
|
expect(record.pairingCode).toBe(request.pairingCode);
|
||||||
|
|
||||||
|
now = 1_710_000_100;
|
||||||
|
const expired = pairing.confirmPairing(record, request.pairingCode);
|
||||||
|
expect(expired).toEqual({ success: false, reason: "expired" });
|
||||||
|
expect(record.pairingStatus).toBe("unpaired");
|
||||||
|
expect(record.pairingCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks notification delivery state transitions", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||||
|
pairing.createPairingRequest(record);
|
||||||
|
|
||||||
|
pairing.markNotificationSent(record);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("sent");
|
||||||
|
expect(record.pairingNotifiedAt).toBe(1_710_000_000);
|
||||||
|
|
||||||
|
pairing.markNotificationFailed(record);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Yonexus.Server RuleRegistry", () => {
|
||||||
|
it("dispatches exact-match rewritten messages to the registered processor", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
const processor = vi.fn();
|
||||||
|
registry.registerRule("chat_sync", processor);
|
||||||
|
|
||||||
|
const handled = registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(processor).toHaveBeenCalledWith("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
expect(registry.hasRule("chat_sync")).toBe(true);
|
||||||
|
expect(registry.getRules()).toEqual(["chat_sync"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reserved and duplicate rule registrations", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
registry.registerRule("chat_sync", () => undefined);
|
||||||
|
|
||||||
|
expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ServerRuleRegistryError);
|
||||||
|
expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow(
|
||||||
|
"Rule 'chat_sync' is already registered"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no processor matches a rewritten message", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
|
||||||
|
expect(registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
486
tests/pairing-auth-liveness.test.ts
Normal file
486
tests/pairing-auth-liveness.test.ts
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createClientRecord, type ClientRecord } from "../plugin/core/persistence.js";
|
||||||
|
import { createServerRuleRegistry, ServerRuleRegistryError } from "../plugin/core/rules.js";
|
||||||
|
import { createPairingService } from "../plugin/services/pairing.js";
|
||||||
|
|
||||||
|
// Inline protocol helpers (to avoid submodule dependency in tests)
|
||||||
|
function createAuthRequestSigningInput(input: {
|
||||||
|
secret: string;
|
||||||
|
nonce: string;
|
||||||
|
proofTimestamp: number;
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
secret: input.secret,
|
||||||
|
nonce: input.nonce,
|
||||||
|
timestamp: input.proofTimestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTimestampFresh(
|
||||||
|
proofTimestamp: number,
|
||||||
|
now: number,
|
||||||
|
maxDriftSeconds: number = 10
|
||||||
|
): { ok: true } | { ok: false; reason: "stale_timestamp" | "future_timestamp" } {
|
||||||
|
const drift = proofTimestamp - now;
|
||||||
|
if (Math.abs(drift) < maxDriftSeconds) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, reason: drift < 0 ? "stale_timestamp" : "future_timestamp" };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Yonexus.Server PairingService", () => {
|
||||||
|
it("creates a pending pairing request with ttl metadata", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||||
|
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 180 });
|
||||||
|
|
||||||
|
expect(request.identifier).toBe("client-a");
|
||||||
|
expect(request.ttlSeconds).toBe(180);
|
||||||
|
expect(request.expiresAt).toBe(1_710_000_180);
|
||||||
|
expect(record.pairingStatus).toBe("pending");
|
||||||
|
expect(record.pairingCode).toBe(request.pairingCode);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms a valid pairing and clears pairing-only fields", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_100 });
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 300 });
|
||||||
|
|
||||||
|
const result = pairing.confirmPairing(record, request.pairingCode);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
pairedAt: 1_710_000_100
|
||||||
|
});
|
||||||
|
expect(typeof result.secret).toBe("string");
|
||||||
|
expect(record.pairingStatus).toBe("paired");
|
||||||
|
expect(record.secret).toBe(result.secret);
|
||||||
|
expect(record.pairingCode).toBeUndefined();
|
||||||
|
expect(record.pairingExpiresAt).toBeUndefined();
|
||||||
|
expect(record.pairingNotifyStatus).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects expired and invalid pairing confirmations without dirtying state", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
let now = 1_710_000_000;
|
||||||
|
const pairing = createPairingService({ now: () => now });
|
||||||
|
const request = pairing.createPairingRequest(record, { ttlSeconds: 60 });
|
||||||
|
|
||||||
|
const invalid = pairing.confirmPairing(record, "WRONG-CODE-000");
|
||||||
|
expect(invalid).toEqual({ success: false, reason: "invalid_code" });
|
||||||
|
expect(record.pairingStatus).toBe("pending");
|
||||||
|
expect(record.pairingCode).toBe(request.pairingCode);
|
||||||
|
|
||||||
|
now = 1_710_000_100;
|
||||||
|
const expired = pairing.confirmPairing(record, request.pairingCode);
|
||||||
|
expect(expired).toEqual({ success: false, reason: "expired" });
|
||||||
|
expect(record.pairingStatus).toBe("unpaired");
|
||||||
|
expect(record.pairingCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks notification delivery state transitions", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
const pairing = createPairingService({ now: () => 1_710_000_000 });
|
||||||
|
pairing.createPairingRequest(record);
|
||||||
|
|
||||||
|
pairing.markNotificationSent(record);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("sent");
|
||||||
|
expect(record.pairingNotifiedAt).toBe(1_710_000_000);
|
||||||
|
|
||||||
|
pairing.markNotificationFailed(record);
|
||||||
|
expect(record.pairingNotifyStatus).toBe("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Yonexus.Server RuleRegistry", () => {
|
||||||
|
it("dispatches exact-match rewritten messages to the registered processor", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
const processor = vi.fn();
|
||||||
|
registry.registerRule("chat_sync", processor);
|
||||||
|
|
||||||
|
const handled = registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(processor).toHaveBeenCalledWith("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
expect(registry.hasRule("chat_sync")).toBe(true);
|
||||||
|
expect(registry.getRules()).toEqual(["chat_sync"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reserved and duplicate rule registrations", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
registry.registerRule("chat_sync", () => undefined);
|
||||||
|
|
||||||
|
expect(() => registry.registerRule("builtin", () => undefined)).toThrow(ServerRuleRegistryError);
|
||||||
|
expect(() => registry.registerRule("chat_sync", () => undefined)).toThrow(
|
||||||
|
"Rule 'chat_sync' is already registered"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no processor matches a rewritten message", () => {
|
||||||
|
const registry = createServerRuleRegistry();
|
||||||
|
|
||||||
|
expect(registry.dispatch("chat_sync::client-a::{\"body\":\"hello\"}")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Yonexus.Server Auth Service", () => {
|
||||||
|
it("verifies valid auth request payload", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "test-pk";
|
||||||
|
record.secret = "test-secret";
|
||||||
|
|
||||||
|
const nonce = "RANDOM24CHARACTERSTRINGX";
|
||||||
|
const timestamp = 1_710_000_000;
|
||||||
|
const signingInput = createAuthRequestSigningInput({
|
||||||
|
secret: "test-secret",
|
||||||
|
nonce,
|
||||||
|
proofTimestamp: timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock signature verification (in real impl would use crypto)
|
||||||
|
const mockSignature = `signed:${signingInput}`;
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce,
|
||||||
|
proofTimestamp: timestamp,
|
||||||
|
signature: mockSignature,
|
||||||
|
publicKey: "test-pk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
now: () => timestamp,
|
||||||
|
verifySignature: (sig, input) => sig === `signed:${input}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result).toHaveProperty("authenticatedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth for unpaired client", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{ now: () => 1_710_000_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("not_paired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with mismatched public key", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "expected-pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "different-pk"
|
||||||
|
},
|
||||||
|
{ now: () => 1_710_000_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("public_key_mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with stale timestamp", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
now: () => 1_710_000_100
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("stale_timestamp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with future timestamp", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_100,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{ now: () => 1_710_000_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("future_timestamp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with nonce collision", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
record.recentNonces = [{ nonce: "COLLIDING24CHARSTRINGX", timestamp: 1_710_000_000 }];
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "COLLIDING24CHARSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_010,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{ now: () => 1_710_000_010 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("nonce_collision");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects auth with rate limit exceeded", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
const now = 1_710_000_000;
|
||||||
|
record.recentHandshakeAttempts = Array(11).fill(now - 5);
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARSTRINGX01",
|
||||||
|
proofTimestamp: now,
|
||||||
|
signature: "sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{ now: () => now }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("rate_limited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalid signature triggers re_pair_required", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARSTRINGX01",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "invalid-sig",
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
now: () => 1_710_000_000,
|
||||||
|
verifySignature: () => false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.reason).toBe("re_pair_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks successful auth attempt in record", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.pairingStatus = "paired";
|
||||||
|
record.publicKey = "pk";
|
||||||
|
record.secret = "secret";
|
||||||
|
|
||||||
|
const now = 1_710_000_000;
|
||||||
|
const nonce = "RANDOM24CHARSTRINGX01";
|
||||||
|
|
||||||
|
const signingInput = createAuthRequestSigningInput({
|
||||||
|
secret: "secret",
|
||||||
|
nonce,
|
||||||
|
proofTimestamp: now
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = verifyAuthRequest(
|
||||||
|
record,
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce,
|
||||||
|
proofTimestamp: now,
|
||||||
|
signature: `signed:${signingInput}`,
|
||||||
|
publicKey: "pk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
now: () => now,
|
||||||
|
verifySignature: (sig, input) => sig === `signed:${input}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(record.recentNonces).toContainEqual({ nonce, timestamp: now });
|
||||||
|
expect(record.recentHandshakeAttempts).toContain(now);
|
||||||
|
expect(record.lastAuthenticatedAt).toBe(now);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Yonexus.Server Heartbeat / Liveness", () => {
|
||||||
|
it("evaluates client online when recent heartbeat exists", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.lastHeartbeatAt = 1_710_000_000;
|
||||||
|
record.status = "online";
|
||||||
|
|
||||||
|
const status = evaluateLiveness(record, { now: () => 1_710_000_300 });
|
||||||
|
expect(status).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates client unstable after 7 minutes without heartbeat", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.lastHeartbeatAt = 1_710_000_000;
|
||||||
|
record.status = "online";
|
||||||
|
|
||||||
|
const status = evaluateLiveness(record, { now: () => 1_710_000_420 });
|
||||||
|
expect(status).toBe("unstable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates client offline after 11 minutes without heartbeat", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
record.lastHeartbeatAt = 1_710_000_000;
|
||||||
|
record.status = "online";
|
||||||
|
|
||||||
|
const status = evaluateLiveness(record, { now: () => 1_710_000_660 });
|
||||||
|
expect(status).toBe("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles client with no heartbeat record", () => {
|
||||||
|
const record = createClientRecord("client-a");
|
||||||
|
|
||||||
|
const status = evaluateLiveness(record, { now: () => 1_710_000_000 });
|
||||||
|
expect(status).toBe("offline");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function evaluateLiveness(
|
||||||
|
record: ReturnType<typeof createClientRecord>,
|
||||||
|
options: { now: () => number }
|
||||||
|
): "online" | "unstable" | "offline" {
|
||||||
|
const now = options.now();
|
||||||
|
const lastHeartbeat = record.lastHeartbeatAt;
|
||||||
|
|
||||||
|
if (!lastHeartbeat) {
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = now - lastHeartbeat;
|
||||||
|
|
||||||
|
if (elapsed >= 11 * 60) {
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
|
if (elapsed >= 7 * 60) {
|
||||||
|
return "unstable";
|
||||||
|
}
|
||||||
|
return "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthRequestPayload {
|
||||||
|
identifier: string;
|
||||||
|
nonce: string;
|
||||||
|
proofTimestamp: number;
|
||||||
|
signature: string;
|
||||||
|
publicKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthVerifyResult {
|
||||||
|
success: boolean;
|
||||||
|
reason?: string;
|
||||||
|
authenticatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAuthRequest(
|
||||||
|
record: ClientRecord,
|
||||||
|
payload: AuthRequestPayload,
|
||||||
|
options: {
|
||||||
|
now: () => number;
|
||||||
|
verifySignature?: (signature: string, input: string) => boolean;
|
||||||
|
}
|
||||||
|
): AuthVerifyResult {
|
||||||
|
if (record.pairingStatus !== "paired") {
|
||||||
|
return { success: false, reason: "not_paired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.publicKey && record.publicKey !== payload.publicKey) {
|
||||||
|
return { success: false, reason: "public_key_mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampCheck = isTimestampFresh(payload.proofTimestamp, options.now());
|
||||||
|
if (!timestampCheck.ok) {
|
||||||
|
return { success: false, reason: timestampCheck.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonceCollision = record.recentNonces.some((n) => n.nonce === payload.nonce);
|
||||||
|
if (nonceCollision) {
|
||||||
|
return { success: false, reason: "nonce_collision" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = options.now();
|
||||||
|
const recentAttempts = record.recentHandshakeAttempts.filter((t) => now - t < 10_000);
|
||||||
|
if (recentAttempts.length >= 10) {
|
||||||
|
return { success: false, reason: "rate_limited" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingInput = createAuthRequestSigningInput({
|
||||||
|
secret: record.secret!,
|
||||||
|
nonce: payload.nonce,
|
||||||
|
proofTimestamp: payload.proofTimestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValidSignature = options.verifySignature?.(payload.signature, signingInput) ?? true;
|
||||||
|
if (!isValidSignature) {
|
||||||
|
return { success: false, reason: "re_pair_required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
record.recentNonces.push({ nonce: payload.nonce, timestamp: now });
|
||||||
|
if (record.recentNonces.length > 10) {
|
||||||
|
record.recentNonces.shift();
|
||||||
|
}
|
||||||
|
record.recentHandshakeAttempts.push(now);
|
||||||
|
record.lastAuthenticatedAt = now;
|
||||||
|
record.lastHeartbeatAt = now;
|
||||||
|
|
||||||
|
return { success: true, authenticatedAt: now };
|
||||||
|
}
|
||||||
390
tests/runtime-flow.test.ts
Normal file
390
tests/runtime-flow.test.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
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<ClientRecord>) => {
|
||||||
|
persisted.clear();
|
||||||
|
for (const client of clients) {
|
||||||
|
persisted.set(client.identifier, client);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockTransport() {
|
||||||
|
const sentToConnection: Array<{ connection: ClientConnection; message: string }> = [];
|
||||||
|
const sentByIdentifier: Array<{ identifier: string; message: string }> = [];
|
||||||
|
const assigned = new Map<object, string>();
|
||||||
|
const promoted: string[] = [];
|
||||||
|
const closed: Array<{ identifier: string; code?: number; reason?: string }> = [];
|
||||||
|
|
||||||
|
const transport: ServerTransport = {
|
||||||
|
isRunning: false,
|
||||||
|
connections: new Map(),
|
||||||
|
start: vi.fn(async () => undefined),
|
||||||
|
stop: vi.fn(async () => undefined),
|
||||||
|
send: vi.fn((identifier: string, message: string) => {
|
||||||
|
sentByIdentifier.push({ identifier, message });
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
sendToConnection: vi.fn((connection: ClientConnection, message: string) => {
|
||||||
|
sentToConnection.push({ connection, message });
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
broadcast: vi.fn(),
|
||||||
|
closeConnection: vi.fn((identifier: string, code?: number, reason?: string) => {
|
||||||
|
closed.push({ identifier, code, reason });
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
promoteToAuthenticated: vi.fn((identifier: string, _ws) => {
|
||||||
|
promoted.push(identifier);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
removeTempConnection: vi.fn(),
|
||||||
|
assignIdentifierToTemp: vi.fn((ws, identifier: string) => {
|
||||||
|
assigned.set(ws as object, identifier);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
sentToConnection,
|
||||||
|
sentByIdentifier,
|
||||||
|
assigned,
|
||||||
|
promoted,
|
||||||
|
closed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Yonexus.Server runtime flow", () => {
|
||||||
|
it("runs hello -> pair_request for an unpaired client", 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(
|
||||||
|
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 () => {
|
||||||
|
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("rejects unauthenticated rule messages by closing the connection", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const transportState = createMockTransport();
|
||||||
|
const runtime = createYonexusServerRuntime({
|
||||||
|
config: {
|
||||||
|
followerIdentifiers: ["client-a"],
|
||||||
|
notifyBotToken: "stub-token",
|
||||||
|
adminUserId: "admin-user",
|
||||||
|
listenHost: "127.0.0.1",
|
||||||
|
listenPort: 8787
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
transport: transportState.transport,
|
||||||
|
now: () => 1_710_000_000
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
const connection = createConnection("client-a");
|
||||||
|
runtime.state.registry.sessions.set("client-a", {
|
||||||
|
identifier: "client-a",
|
||||||
|
socket: connection.ws,
|
||||||
|
isAuthenticated: false,
|
||||||
|
connectedAt: connection.connectedAt,
|
||||||
|
lastActivityAt: 1_710_000_000
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.handleMessage(connection, 'chat_sync::{"body":"hello"}');
|
||||||
|
|
||||||
|
expect((connection.ws.close as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||||
|
1008,
|
||||||
|
"Not authenticated"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks stale authenticated clients unstable then offline during liveness sweep", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
let now = 1_710_000_000;
|
||||||
|
const store = createMockStore();
|
||||||
|
const transportState = createMockTransport();
|
||||||
|
const runtime = createYonexusServerRuntime({
|
||||||
|
config: {
|
||||||
|
followerIdentifiers: ["client-a"],
|
||||||
|
notifyBotToken: "stub-token",
|
||||||
|
adminUserId: "admin-user",
|
||||||
|
listenHost: "127.0.0.1",
|
||||||
|
listenPort: 8787
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
transport: transportState.transport,
|
||||||
|
now: () => now,
|
||||||
|
sweepIntervalMs: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
runtime.state.registry.clients.set("client-a", {
|
||||||
|
identifier: "client-a",
|
||||||
|
pairingStatus: "paired",
|
||||||
|
publicKey: "pk",
|
||||||
|
secret: "secret",
|
||||||
|
status: "online",
|
||||||
|
recentNonces: [],
|
||||||
|
recentHandshakeAttempts: [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastAuthenticatedAt: now,
|
||||||
|
lastHeartbeatAt: now
|
||||||
|
});
|
||||||
|
runtime.state.registry.sessions.set("client-a", {
|
||||||
|
identifier: "client-a",
|
||||||
|
socket: createMockSocket(),
|
||||||
|
isAuthenticated: true,
|
||||||
|
connectedAt: now,
|
||||||
|
lastActivityAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
now += 7 * 60;
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
expect(runtime.state.registry.clients.get("client-a")?.status).toBe("unstable");
|
||||||
|
const unstableNotice = transportState.sentByIdentifier.at(-1);
|
||||||
|
expect(unstableNotice?.identifier).toBe("client-a");
|
||||||
|
expect(decodeBuiltin(unstableNotice!.message).type).toBe("status_update");
|
||||||
|
|
||||||
|
now += 4 * 60;
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
expect(runtime.state.registry.clients.get("client-a")?.status).toBe("offline");
|
||||||
|
expect(transportState.closed).toContainEqual({
|
||||||
|
identifier: "client-a",
|
||||||
|
code: 1001,
|
||||||
|
reason: "Heartbeat timeout"
|
||||||
|
});
|
||||||
|
expect(runtime.state.registry.sessions.has("client-a")).toBe(false);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"servers/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node"
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user