feat(server): add pairing service and notify stub
This commit is contained in:
@@ -19,6 +19,11 @@ import {
|
|||||||
} from "./persistence.js";
|
} from "./persistence.js";
|
||||||
import type { YonexusServerStore } from "./store.js";
|
import type { YonexusServerStore } from "./store.js";
|
||||||
import { type ClientConnection, type ServerTransport } from "./transport.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 {
|
export interface YonexusServerRuntimeOptions {
|
||||||
config: YonexusServerConfig;
|
config: YonexusServerConfig;
|
||||||
@@ -36,7 +41,10 @@ export class YonexusServerRuntime {
|
|||||||
private readonly options: YonexusServerRuntimeOptions;
|
private readonly options: YonexusServerRuntimeOptions;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
private readonly registry: ServerRegistry;
|
private readonly registry: ServerRegistry;
|
||||||
|
private readonly pairingService: PairingService;
|
||||||
|
private readonly notificationService: DiscordNotificationService;
|
||||||
private started = false;
|
private started = false;
|
||||||
|
|
||||||
constructor(options: YonexusServerRuntimeOptions) {
|
constructor(options: YonexusServerRuntimeOptions) {
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
||||||
@@ -44,6 +52,11 @@ export class YonexusServerRuntime {
|
|||||||
clients: new Map(),
|
clients: new Map(),
|
||||||
sessions: 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 {
|
get state(): ServerLifecycleState {
|
||||||
@@ -180,6 +193,10 @@ export class YonexusServerRuntime {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (nextAction === "pair_required") {
|
||||||
|
await this.beginPairing(record);
|
||||||
|
}
|
||||||
|
|
||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +223,20 @@ export class YonexusServerRuntime {
|
|||||||
return created;
|
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> {
|
private async persist(): Promise<void> {
|
||||||
await this.options.store.save(this.registry.clients.values());
|
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
|
type ServerLifecycleState
|
||||||
} from "./core/runtime.js";
|
} 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 };
|
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