feat(server): add pairing service and notify stub
This commit is contained in:
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