From cd09fe6043db70f76905663eba43d236d0c3de07 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:34:46 +0000 Subject: [PATCH] feat(server): add pairing service and notify stub --- plugin/core/runtime.ts | 31 ++++++ plugin/crypto/utils.ts | 42 +++++++ plugin/index.ts | 15 +++ plugin/notifications/discord.ts | 97 ++++++++++++++++ plugin/services/pairing.ts | 190 ++++++++++++++++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 plugin/crypto/utils.ts create mode 100644 plugin/notifications/discord.ts create mode 100644 plugin/services/pairing.ts diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index c6f212c..2e2ac5a 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -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 { + 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 { await this.options.store.save(this.registry.clients.values()); } diff --git a/plugin/crypto/utils.ts b/plugin/crypto/utils.ts new file mode 100644 index 0000000..29dd51b --- /dev/null +++ b/plugin/crypto/utils.ts @@ -0,0 +1,42 @@ +import { randomBytes } from "node:crypto"; + +/** + * Generate a cryptographically secure random pairing code. + * Format: XXXX-XXXX-XXXX (12 alphanumeric characters in groups of 4) + * Excludes confusing characters: 0, O, 1, I + */ +export function generatePairingCode(): string { + const bytes = randomBytes(8); + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Excludes confusing chars (0, O, 1, I) + + let code = ""; + for (let i = 0; i < 12; i++) { + code += chars[bytes[i % bytes.length] % chars.length]; + } + + // Format as XXXX-XXXX-XXXX + return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`; +} + +/** + * Generate a shared secret for client authentication. + * This is issued by the server after successful pairing. + * Returns a base64url-encoded 32-byte random string. + */ +export function generateSecret(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Generate a 24-character nonce for authentication. + * Uses base64url encoding of 18 random bytes, truncated to 24 chars. + */ +export function generateNonce(): string { + const bytes = randomBytes(18); + return bytes.toString("base64url").slice(0, 24); +} + +/** + * Default pairing code TTL in seconds (5 minutes) + */ +export const DEFAULT_PAIRING_TTL_SECONDS = 300; diff --git a/plugin/index.ts b/plugin/index.ts index 95c77be..94840cb 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -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 }; diff --git a/plugin/notifications/discord.ts b/plugin/notifications/discord.ts new file mode 100644 index 0000000..047d3f2 --- /dev/null +++ b/plugin/notifications/discord.ts @@ -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; +} + +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 { + 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 { + console.log("[Yonexus.Server] Mock pairing notification:"); + console.log(` Identifier: ${request.identifier}`); + console.log(` Pairing Code: ${request.pairingCode}`); + console.log(` Success: ${shouldSucceed}`); + return shouldSucceed; + } + }; +} diff --git a/plugin/services/pairing.ts b/plugin/services/pairing.ts new file mode 100644 index 0000000..3245942 --- /dev/null +++ b/plugin/services/pairing.ts @@ -0,0 +1,190 @@ +/** + * Yonexus Server - Pairing Service + * + * Manages client pairing flow: + * - Creating pairing requests with codes + * - Tracking pairing expiration + * - Validating pairing confirmations + * - Issuing shared secrets after successful pairing + */ + +import type { ClientRecord } from "../core/persistence.js"; +import { generatePairingCode, generateSecret, DEFAULT_PAIRING_TTL_SECONDS } from "../crypto/utils.js"; + +export interface PairingRequest { + readonly identifier: string; + readonly pairingCode: string; + readonly expiresAt: number; + readonly ttlSeconds: number; + readonly createdAt: number; +} + +export interface PairingResult { + readonly success: boolean; + readonly secret?: string; + readonly pairedAt?: number; + readonly reason?: PairingFailureReason; +} + +export type PairingFailureReason = + | "expired" + | "invalid_code" + | "not_pending" + | "internal_error"; + +export class PairingService { + private readonly now: () => number; + + constructor(options: { now?: () => number } = {}) { + this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); + } + + /** + * Create a new pairing request for a client. + * Updates the client record with pending pairing state. + */ + createPairingRequest( + record: ClientRecord, + options: { ttlSeconds?: number } = {} + ): PairingRequest { + const ttlSeconds = options.ttlSeconds ?? DEFAULT_PAIRING_TTL_SECONDS; + const now = this.now(); + const pairingCode = generatePairingCode(); + + // Update the client record + record.pairingStatus = "pending"; + record.pairingCode = pairingCode; + record.pairingExpiresAt = now + ttlSeconds; + record.pairingNotifyStatus = "pending"; + record.updatedAt = now; + + return { + identifier: record.identifier, + pairingCode, + expiresAt: record.pairingExpiresAt, + ttlSeconds, + createdAt: now + }; + } + + /** + * Validate a pairing confirmation from a client. + * Returns the pairing result and updates the record on success. + */ + confirmPairing( + record: ClientRecord, + submittedCode: string + ): PairingResult { + const now = this.now(); + + // Check if pairing is pending + if (record.pairingStatus !== "pending") { + return { success: false, reason: "not_pending" }; + } + + // Check if pairing has expired + if (record.pairingExpiresAt && now > record.pairingExpiresAt) { + this.clearPairingState(record); + return { success: false, reason: "expired" }; + } + + // Validate the pairing code + if (record.pairingCode !== submittedCode) { + return { success: false, reason: "invalid_code" }; + } + + // Pairing successful - generate secret and update record + const secret = generateSecret(); + record.pairingStatus = "paired"; + record.secret = secret; + record.pairedAt = now; + record.updatedAt = now; + + // Clear pairing-specific fields + record.pairingCode = undefined; + record.pairingExpiresAt = undefined; + record.pairingNotifiedAt = undefined; + record.pairingNotifyStatus = undefined; + + return { + success: true, + secret, + pairedAt: now + }; + } + + /** + * Mark a pairing notification as sent. + */ + markNotificationSent(record: ClientRecord): void { + if (record.pairingStatus === "pending") { + record.pairingNotifyStatus = "sent"; + record.pairingNotifiedAt = this.now(); + record.updatedAt = this.now(); + } + } + + /** + * Mark a pairing notification as failed. + */ + markNotificationFailed(record: ClientRecord): void { + if (record.pairingStatus === "pending") { + record.pairingNotifyStatus = "failed"; + record.updatedAt = this.now(); + } + } + + /** + * Clear pairing state for a client. + * Used when pairing fails or is cancelled. + */ + clearPairingState(record: ClientRecord): void { + record.pairingStatus = record.pairingStatus === "paired" ? "paired" : "unpaired"; + record.pairingCode = undefined; + record.pairingExpiresAt = undefined; + record.pairingNotifiedAt = undefined; + record.pairingNotifyStatus = undefined; + record.updatedAt = this.now(); + } + + /** + * Revoke pairing for a client. + * Clears secret and returns to unpaired state. + */ + revokePairing(record: ClientRecord): void { + record.pairingStatus = "revoked"; + record.secret = undefined; + record.publicKey = undefined; + record.pairedAt = undefined; + this.clearPairingState(record); + } + + /** + * Check if a pairing request is expired. + */ + isExpired(record: ClientRecord): boolean { + if (!record.pairingExpiresAt) return false; + return this.now() > record.pairingExpiresAt; + } + + /** + * Get remaining TTL for a pending pairing. + * Returns 0 if expired or not pending. + */ + getRemainingTtl(record: ClientRecord): number { + if (record.pairingStatus !== "pending" || !record.pairingExpiresAt) { + return 0; + } + const remaining = record.pairingExpiresAt - this.now(); + return Math.max(0, remaining); + } +} + +/** + * Factory function to create a pairing service. + */ +export function createPairingService( + options: { now?: () => number } = {} +): PairingService { + return new PairingService(options); +}