import { randomUUID } from "node:crypto"; import { createAsyncLock, pruneExpiredPending, readJsonFile, resolvePairingPaths, writeJsonAtomic, } from "./pairing-files.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; export type NodePairingPendingRequest = { requestId: string; nodeId: string; displayName?: string; platform?: string; version?: string; coreVersion?: string; uiVersion?: string; deviceFamily?: string; modelIdentifier?: string; caps?: string[]; commands?: string[]; permissions?: Record; remoteIp?: string; silent?: boolean; isRepair?: boolean; ts: number; }; export type NodePairingPairedNode = { nodeId: string; token: string; displayName?: string; platform?: string; version?: string; coreVersion?: string; uiVersion?: string; deviceFamily?: string; modelIdentifier?: string; caps?: string[]; commands?: string[]; bins?: string[]; permissions?: Record; remoteIp?: string; createdAtMs: number; approvedAtMs: number; lastConnectedAtMs?: number; }; export type NodePairingList = { pending: NodePairingPendingRequest[]; paired: NodePairingPairedNode[]; }; type NodePairingStateFile = { pendingById: Record; pairedByNodeId: Record; }; const PENDING_TTL_MS = 5 * 60 * 1000; const withLock = createAsyncLock(); async function loadState(baseDir?: string): Promise { const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes"); const [pending, paired] = await Promise.all([ readJsonFile>(pendingPath), readJsonFile>(pairedPath), ]); const state: NodePairingStateFile = { pendingById: pending ?? {}, pairedByNodeId: paired ?? {}, }; pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS); return state; } async function persistState(state: NodePairingStateFile, baseDir?: string) { const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes"); await Promise.all([ writeJsonAtomic(pendingPath, state.pendingById), writeJsonAtomic(pairedPath, state.pairedByNodeId), ]); } function normalizeNodeId(nodeId: string) { return nodeId.trim(); } function newToken() { return generatePairingToken(); } export async function listNodePairing(baseDir?: string): Promise { const state = await loadState(baseDir); const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts); const paired = Object.values(state.pairedByNodeId).toSorted( (a, b) => b.approvedAtMs - a.approvedAtMs, ); return { pending, paired }; } export async function getPairedNode( nodeId: string, baseDir?: string, ): Promise { const state = await loadState(baseDir); return state.pairedByNodeId[normalizeNodeId(nodeId)] ?? null; } export async function requestNodePairing( req: Omit, baseDir?: string, ): Promise<{ status: "pending"; request: NodePairingPendingRequest; created: boolean; }> { return await withLock(async () => { const state = await loadState(baseDir); const nodeId = normalizeNodeId(req.nodeId); if (!nodeId) { throw new Error("nodeId required"); } const existing = Object.values(state.pendingById).find((p) => p.nodeId === nodeId); if (existing) { return { status: "pending", request: existing, created: false }; } const isRepair = Boolean(state.pairedByNodeId[nodeId]); const request: NodePairingPendingRequest = { requestId: randomUUID(), nodeId, displayName: req.displayName, platform: req.platform, version: req.version, coreVersion: req.coreVersion, uiVersion: req.uiVersion, deviceFamily: req.deviceFamily, modelIdentifier: req.modelIdentifier, caps: req.caps, commands: req.commands, permissions: req.permissions, remoteIp: req.remoteIp, silent: req.silent, isRepair, ts: Date.now(), }; state.pendingById[request.requestId] = request; await persistState(state, baseDir); return { status: "pending", request, created: true }; }); } export async function approveNodePairing( requestId: string, baseDir?: string, ): Promise<{ requestId: string; node: NodePairingPairedNode } | null> { return await withLock(async () => { const state = await loadState(baseDir); const pending = state.pendingById[requestId]; if (!pending) { return null; } const now = Date.now(); const existing = state.pairedByNodeId[pending.nodeId]; const node: NodePairingPairedNode = { nodeId: pending.nodeId, token: newToken(), displayName: pending.displayName, platform: pending.platform, version: pending.version, coreVersion: pending.coreVersion, uiVersion: pending.uiVersion, deviceFamily: pending.deviceFamily, modelIdentifier: pending.modelIdentifier, caps: pending.caps, commands: pending.commands, permissions: pending.permissions, remoteIp: pending.remoteIp, createdAtMs: existing?.createdAtMs ?? now, approvedAtMs: now, }; delete state.pendingById[requestId]; state.pairedByNodeId[pending.nodeId] = node; await persistState(state, baseDir); return { requestId, node }; }); } export async function rejectNodePairing( requestId: string, baseDir?: string, ): Promise<{ requestId: string; nodeId: string } | null> { return await withLock(async () => { const state = await loadState(baseDir); const pending = state.pendingById[requestId]; if (!pending) { return null; } delete state.pendingById[requestId]; await persistState(state, baseDir); return { requestId, nodeId: pending.nodeId }; }); } export async function verifyNodeToken( nodeId: string, token: string, baseDir?: string, ): Promise<{ ok: boolean; node?: NodePairingPairedNode }> { const state = await loadState(baseDir); const normalized = normalizeNodeId(nodeId); const node = state.pairedByNodeId[normalized]; if (!node) { return { ok: false }; } return verifyPairingToken(token, node.token) ? { ok: true, node } : { ok: false }; } export async function updatePairedNodeMetadata( nodeId: string, patch: Partial>, baseDir?: string, ) { await withLock(async () => { const state = await loadState(baseDir); const normalized = normalizeNodeId(nodeId); const existing = state.pairedByNodeId[normalized]; if (!existing) { return; } const next: NodePairingPairedNode = { ...existing, displayName: patch.displayName ?? existing.displayName, platform: patch.platform ?? existing.platform, version: patch.version ?? existing.version, coreVersion: patch.coreVersion ?? existing.coreVersion, uiVersion: patch.uiVersion ?? existing.uiVersion, deviceFamily: patch.deviceFamily ?? existing.deviceFamily, modelIdentifier: patch.modelIdentifier ?? existing.modelIdentifier, remoteIp: patch.remoteIp ?? existing.remoteIp, caps: patch.caps ?? existing.caps, commands: patch.commands ?? existing.commands, bins: patch.bins ?? existing.bins, permissions: patch.permissions ?? existing.permissions, lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs, }; state.pairedByNodeId[normalized] = next; await persistState(state, baseDir); }); } export async function renamePairedNode( nodeId: string, displayName: string, baseDir?: string, ): Promise { return await withLock(async () => { const state = await loadState(baseDir); const normalized = normalizeNodeId(nodeId); const existing = state.pairedByNodeId[normalized]; if (!existing) { return null; } const trimmed = displayName.trim(); if (!trimmed) { throw new Error("displayName required"); } const next: NodePairingPairedNode = { ...existing, displayName: trimmed }; state.pairedByNodeId[normalized] = next; await persistState(state, baseDir); return next; }); }