diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 97d6688659..2437987081 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -1,8 +1,12 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { resolveStateDir } from "../config/paths.js"; import { safeEqualSecret } from "../security/secret-equal.js"; +import { + createAsyncLock, + pruneExpiredPending, + readJsonFile, + resolvePairingPaths, + writeJsonAtomic, +} from "./pairing-files.js"; export type DevicePairingPendingRequest = { requestId: string; @@ -68,88 +72,27 @@ type DevicePairingStateFile = { const PENDING_TTL_MS = 5 * 60 * 1000; -function resolvePaths(baseDir?: string) { - const root = baseDir ?? resolveStateDir(); - const dir = path.join(root, "devices"); - 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"); - try { - await fs.chmod(tmp, 0o600); - } catch { - // best-effort - } - await fs.rename(tmp, filePath); - try { - await fs.chmod(filePath, 0o600); - } catch { - // best-effort - } -} - -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?.(); - } -} +const withLock = createAsyncLock(); async function loadState(baseDir?: string): Promise { - const { pendingPath, pairedPath } = resolvePaths(baseDir); + const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices"); const [pending, paired] = await Promise.all([ - readJSON>(pendingPath), - readJSON>(pairedPath), + readJsonFile>(pendingPath), + readJsonFile>(pairedPath), ]); const state: DevicePairingStateFile = { pendingById: pending ?? {}, pairedByDeviceId: paired ?? {}, }; - pruneExpiredPending(state.pendingById, Date.now()); + pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS); return state; } async function persistState(state: DevicePairingStateFile, baseDir?: string) { - const { pendingPath, pairedPath } = resolvePaths(baseDir); + const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices"); await Promise.all([ - writeJSONAtomic(pendingPath, state.pendingById), - writeJSONAtomic(pairedPath, state.pairedByDeviceId), + writeJsonAtomic(pendingPath, state.pendingById), + writeJsonAtomic(pairedPath, state.pairedByDeviceId), ]); } diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 0d1089e824..0b1bf245b1 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -1,7 +1,11 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { resolveStateDir } from "../config/paths.js"; +import { + createAsyncLock, + pruneExpiredPending, + readJsonFile, + resolvePairingPaths, + writeJsonAtomic, +} from "./pairing-files.js"; export type NodePairingPendingRequest = { requestId: string; @@ -54,88 +58,27 @@ type NodePairingStateFile = { const PENDING_TTL_MS = 5 * 60 * 1000; -function resolvePaths(baseDir?: string) { - const root = baseDir ?? resolveStateDir(); - 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"); - try { - await fs.chmod(tmp, 0o600); - } catch { - // best-effort; ignore on platforms without chmod - } - await fs.rename(tmp, filePath); - try { - await fs.chmod(filePath, 0o600); - } catch { - // best-effort; ignore on platforms without chmod - } -} - -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?.(); - } -} +const withLock = createAsyncLock(); async function loadState(baseDir?: string): Promise { - const { pendingPath, pairedPath } = resolvePaths(baseDir); + const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes"); const [pending, paired] = await Promise.all([ - readJSON>(pendingPath), - readJSON>(pairedPath), + readJsonFile>(pendingPath), + readJsonFile>(pairedPath), ]); const state: NodePairingStateFile = { pendingById: pending ?? {}, pairedByNodeId: paired ?? {}, }; - pruneExpiredPending(state.pendingById, Date.now()); + pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS); return state; } async function persistState(state: NodePairingStateFile, baseDir?: string) { - const { pendingPath, pairedPath } = resolvePaths(baseDir); + const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes"); await Promise.all([ - writeJSONAtomic(pendingPath, state.pendingById), - writeJSONAtomic(pairedPath, state.pairedByNodeId), + writeJsonAtomic(pendingPath, state.pendingById), + writeJsonAtomic(pairedPath, state.pairedByNodeId), ]); } diff --git a/src/infra/pairing-files.ts b/src/infra/pairing-files.ts new file mode 100644 index 0000000000..fb6c3ee5eb --- /dev/null +++ b/src/infra/pairing-files.ts @@ -0,0 +1,70 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; + +export function resolvePairingPaths(baseDir: string | undefined, subdir: string) { + const root = baseDir ?? resolveStateDir(); + const dir = path.join(root, subdir); + return { + dir, + pendingPath: path.join(dir, "pending.json"), + pairedPath: path.join(dir, "paired.json"), + }; +} + +export async function readJsonFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export 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"); + try { + await fs.chmod(tmp, 0o600); + } catch { + // best-effort; ignore on platforms without chmod + } + await fs.rename(tmp, filePath); + try { + await fs.chmod(filePath, 0o600); + } catch { + // best-effort; ignore on platforms without chmod + } +} + +export function pruneExpiredPending( + pendingById: Record, + nowMs: number, + ttlMs: number, +) { + for (const [id, req] of Object.entries(pendingById)) { + if (nowMs - req.ts > ttlMs) { + delete pendingById[id]; + } + } +} + +export function createAsyncLock() { + let lock: Promise = Promise.resolve(); + return 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?.(); + } + }; +}