dev/2026-04-08 #1
@@ -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
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;
|
||||
@@ -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 };
|
||||
|
||||
97
plugin/notifications/discord.ts
Normal file
97
plugin/notifications/discord.ts
Normal 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
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);
|
||||
}
|
||||
Reference in New Issue
Block a user