dev/2026-04-08 #1

Merged
hzhang merged 29 commits from dev/2026-04-08 into main 2026-04-13 09:34:22 +00:00
5 changed files with 375 additions and 0 deletions
Showing only changes of commit cd09fe6043 - Show all commits

View File

@@ -19,6 +19,11 @@ import {
} from "./persistence.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";
export interface YonexusServerRuntimeOptions {
config: YonexusServerConfig;
@@ -36,7 +41,10 @@ export class YonexusServerRuntime {
private readonly options: YonexusServerRuntimeOptions;
private readonly now: () => number;
private readonly registry: ServerRegistry;
private readonly pairingService: PairingService;
private readonly notificationService: DiscordNotificationService;
private started = false;
constructor(options: YonexusServerRuntimeOptions) {
this.options = options;
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
@@ -44,6 +52,11 @@ export class YonexusServerRuntime {
clients: new Map(),
sessions: new Map()
};
this.pairingService = createPairingService({ now: this.now });
this.notificationService = createDiscordNotificationService({
botToken: options.config.notifyBotToken,
adminUserId: options.config.adminUserId
});
}
get state(): ServerLifecycleState {
@@ -180,6 +193,10 @@ export class YonexusServerRuntime {
)
);
if (nextAction === "pair_required") {
await this.beginPairing(record);
}
await this.persist();
}
@@ -206,6 +223,20 @@ export class YonexusServerRuntime {
return created;
}
private async beginPairing(record: ClientRecord): Promise<void> {
if (hasPendingPairing(record) && !isPairingExpired(record, this.now())) {
return;
}
const request = this.pairingService.createPairingRequest(record);
const notified = await this.notificationService.sendPairingNotification(request);
if (notified) {
this.pairingService.markNotificationSent(record);
} else {
this.pairingService.markNotificationFailed(record);
}
}
private async persist(): Promise<void> {
await this.options.store.save(this.registry.clients.values());
}

42
plugin/crypto/utils.ts Normal file
View File

@@ -0,0 +1,42 @@
import { randomBytes } from "node:crypto";
/**
* Generate a cryptographically secure random pairing code.
* Format: XXXX-XXXX-XXXX (12 alphanumeric characters in groups of 4)
* Excludes confusing characters: 0, O, 1, I
*/
export function generatePairingCode(): string {
const bytes = randomBytes(8);
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Excludes confusing chars (0, O, 1, I)
let code = "";
for (let i = 0; i < 12; i++) {
code += chars[bytes[i % bytes.length] % chars.length];
}
// Format as XXXX-XXXX-XXXX
return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`;
}
/**
* Generate a shared secret for client authentication.
* This is issued by the server after successful pairing.
* Returns a base64url-encoded 32-byte random string.
*/
export function generateSecret(): string {
return randomBytes(32).toString("base64url");
}
/**
* Generate a 24-character nonce for authentication.
* Uses base64url encoding of 18 random bytes, truncated to 24 chars.
*/
export function generateNonce(): string {
const bytes = randomBytes(18);
return bytes.toString("base64url").slice(0, 24);
}
/**
* Default pairing code TTL in seconds (5 minutes)
*/
export const DEFAULT_PAIRING_TTL_SECONDS = 300;

View File

@@ -74,4 +74,19 @@ export {
type ServerLifecycleState
} from "./core/runtime.js";
export {
createPairingService,
PairingService,
type PairingRequest,
type PairingResult,
type PairingFailureReason
} from "./services/pairing.js";
export {
createDiscordNotificationService,
createMockNotificationService,
type DiscordNotificationService,
type DiscordNotificationConfig
} from "./notifications/discord.js";
export { manifest };

View File

@@ -0,0 +1,97 @@
/**
* Yonexus Server - Discord Notification Service
*
* Sends pairing notifications to the configured admin user via Discord DM.
*/
import type { PairingRequest } from "../services/pairing.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 message = formatPairingMessage(request);
// Log to console (visible in OpenClaw logs)
console.log("[Yonexus.Server] Pairing notification (Discord DM stub):");
console.log(message);
// 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.
*/
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: ${request.pairingCode}`);
console.log(` Success: ${shouldSucceed}`);
return shouldSucceed;
}
};
}

190
plugin/services/pairing.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* Yonexus Server - Pairing Service
*
* Manages client pairing flow:
* - Creating pairing requests with codes
* - Tracking pairing expiration
* - Validating pairing confirmations
* - Issuing shared secrets after successful pairing
*/
import type { ClientRecord } from "../core/persistence.js";
import { generatePairingCode, generateSecret, DEFAULT_PAIRING_TTL_SECONDS } from "../crypto/utils.js";
export interface PairingRequest {
readonly identifier: string;
readonly pairingCode: string;
readonly expiresAt: number;
readonly ttlSeconds: number;
readonly createdAt: number;
}
export interface PairingResult {
readonly success: boolean;
readonly secret?: string;
readonly pairedAt?: number;
readonly reason?: PairingFailureReason;
}
export type PairingFailureReason =
| "expired"
| "invalid_code"
| "not_pending"
| "internal_error";
export class PairingService {
private readonly now: () => number;
constructor(options: { now?: () => number } = {}) {
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
}
/**
* Create a new pairing request for a client.
* Updates the client record with pending pairing state.
*/
createPairingRequest(
record: ClientRecord,
options: { ttlSeconds?: number } = {}
): PairingRequest {
const ttlSeconds = options.ttlSeconds ?? DEFAULT_PAIRING_TTL_SECONDS;
const now = this.now();
const pairingCode = generatePairingCode();
// Update the client record
record.pairingStatus = "pending";
record.pairingCode = pairingCode;
record.pairingExpiresAt = now + ttlSeconds;
record.pairingNotifyStatus = "pending";
record.updatedAt = now;
return {
identifier: record.identifier,
pairingCode,
expiresAt: record.pairingExpiresAt,
ttlSeconds,
createdAt: now
};
}
/**
* Validate a pairing confirmation from a client.
* Returns the pairing result and updates the record on success.
*/
confirmPairing(
record: ClientRecord,
submittedCode: string
): PairingResult {
const now = this.now();
// Check if pairing is pending
if (record.pairingStatus !== "pending") {
return { success: false, reason: "not_pending" };
}
// Check if pairing has expired
if (record.pairingExpiresAt && now > record.pairingExpiresAt) {
this.clearPairingState(record);
return { success: false, reason: "expired" };
}
// Validate the pairing code
if (record.pairingCode !== submittedCode) {
return { success: false, reason: "invalid_code" };
}
// Pairing successful - generate secret and update record
const secret = generateSecret();
record.pairingStatus = "paired";
record.secret = secret;
record.pairedAt = now;
record.updatedAt = now;
// Clear pairing-specific fields
record.pairingCode = undefined;
record.pairingExpiresAt = undefined;
record.pairingNotifiedAt = undefined;
record.pairingNotifyStatus = undefined;
return {
success: true,
secret,
pairedAt: now
};
}
/**
* Mark a pairing notification as sent.
*/
markNotificationSent(record: ClientRecord): void {
if (record.pairingStatus === "pending") {
record.pairingNotifyStatus = "sent";
record.pairingNotifiedAt = this.now();
record.updatedAt = this.now();
}
}
/**
* Mark a pairing notification as failed.
*/
markNotificationFailed(record: ClientRecord): void {
if (record.pairingStatus === "pending") {
record.pairingNotifyStatus = "failed";
record.updatedAt = this.now();
}
}
/**
* Clear pairing state for a client.
* Used when pairing fails or is cancelled.
*/
clearPairingState(record: ClientRecord): void {
record.pairingStatus = record.pairingStatus === "paired" ? "paired" : "unpaired";
record.pairingCode = undefined;
record.pairingExpiresAt = undefined;
record.pairingNotifiedAt = undefined;
record.pairingNotifyStatus = undefined;
record.updatedAt = this.now();
}
/**
* Revoke pairing for a client.
* Clears secret and returns to unpaired state.
*/
revokePairing(record: ClientRecord): void {
record.pairingStatus = "revoked";
record.secret = undefined;
record.publicKey = undefined;
record.pairedAt = undefined;
this.clearPairingState(record);
}
/**
* Check if a pairing request is expired.
*/
isExpired(record: ClientRecord): boolean {
if (!record.pairingExpiresAt) return false;
return this.now() > record.pairingExpiresAt;
}
/**
* Get remaining TTL for a pending pairing.
* Returns 0 if expired or not pending.
*/
getRemainingTtl(record: ClientRecord): number {
if (record.pairingStatus !== "pending" || !record.pairingExpiresAt) {
return 0;
}
const remaining = record.pairingExpiresAt - this.now();
return Math.max(0, remaining);
}
}
/**
* Factory function to create a pairing service.
*/
export function createPairingService(
options: { now?: () => number } = {}
): PairingService {
return new PairingService(options);
}