feat(server): add pairing service and notify stub

This commit is contained in:
nav
2026-04-08 21:34:46 +00:00
parent f7c7531385
commit cd09fe6043
5 changed files with 375 additions and 0 deletions

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);
}