import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; export type NodePairingPendingRequest = { requestId: string; nodeId: string; displayName?: string; platform?: string; version?: 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; deviceFamily?: string; modelIdentifier?: string; caps?: string[]; commands?: string[]; permissions?: Record; remoteIp?: string; createdAtMs: number; approvedAtMs: number; }; export type NodePairingList = { pending: NodePairingPendingRequest[]; paired: NodePairingPairedNode[]; }; type NodePairingStateFile = { pendingById: Record; pairedByNodeId: Record; }; const PENDING_TTL_MS = 5 * 60 * 1000; function defaultBaseDir() { return path.join(os.homedir(), ".clawdis"); } function resolvePaths(baseDir?: string) { const root = baseDir ?? defaultBaseDir(); const dir = path.join(root, "nodes"); return { dir, pendingPath: path.join(dir, "pending.json"), pairedPath: path.join(dir, "paired.json"), }; } async function readJSON(filePath: string): Promise { try { const raw = await fs.readFile(filePath, "utf8"); return JSON.parse(raw) as T; } catch { return null; } } async function writeJSONAtomic(filePath: string, value: unknown) { const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); const tmp = `${filePath}.${randomUUID()}.tmp`; await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); await fs.rename(tmp, filePath); } function pruneExpiredPending( pendingById: Record, nowMs: number, ) { for (const [id, req] of Object.entries(pendingById)) { if (nowMs - req.ts > PENDING_TTL_MS) { delete pendingById[id]; } } } let lock: Promise = Promise.resolve(); async function withLock(fn: () => Promise): Promise { const prev = lock; let release: (() => void) | undefined; lock = new Promise((resolve) => { release = resolve; }); await prev; try { return await fn(); } finally { release?.(); } } async function loadState(baseDir?: string): Promise { const { pendingPath, pairedPath } = resolvePaths(baseDir); const [pending, paired] = await Promise.all([ readJSON>(pendingPath), readJSON>(pairedPath), ]); const state: NodePairingStateFile = { pendingById: pending ?? {}, pairedByNodeId: paired ?? {}, }; pruneExpiredPending(state.pendingById, Date.now()); return state; } async function persistState(state: NodePairingStateFile, baseDir?: string) { const { pendingPath, pairedPath } = resolvePaths(baseDir); await Promise.all([ writeJSONAtomic(pendingPath, state.pendingById), writeJSONAtomic(pairedPath, state.pairedByNodeId), ]); } function normalizeNodeId(nodeId: string) { return nodeId.trim(); } function newToken() { return randomUUID().replaceAll("-", ""); } export async function listNodePairing( baseDir?: string, ): Promise { const state = await loadState(baseDir); const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts); const paired = Object.values(state.pairedByNodeId).sort( (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, 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, 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 node.token === token ? { ok: true, node } : { ok: false }; } export async function updatePairedNodeMetadata( nodeId: string, patch: Partial< Omit< NodePairingPairedNode, "nodeId" | "token" | "createdAtMs" | "approvedAtMs" > >, 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, 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, permissions: patch.permissions ?? existing.permissions, }; state.pairedByNodeId[normalized] = next; await persistState(state, baseDir); }); }