diff --git a/frontend/app/lib/claw-auth.ts b/frontend/app/lib/claw-auth.ts index fed9230..4270829 100644 --- a/frontend/app/lib/claw-auth.ts +++ b/frontend/app/lib/claw-auth.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import { cookies } from "next/headers"; @@ -14,6 +14,7 @@ const COOKIE_NAME = authCookieName(); const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000; const LINUX_ACCOUNT_WORKSPACE_NAME = "zk-agent"; +let sessionsWriteQueue: Promise = Promise.resolve(); type UserRecord = { id: string; @@ -131,9 +132,10 @@ export async function logoutAccount() { const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; if (token) { - const sessionsFile = await readSessions(); - delete sessionsFile.sessions[token]; - await writeSessions(sessionsFile); + await mutateSessions((sessionsFile) => { + delete sessionsFile.sessions[token]; + return sessionsFile; + }); } cookieStore.delete(COOKIE_NAME); } @@ -151,8 +153,12 @@ export async function getCurrentAccount(): Promise { const account = usersFile.users.find((user) => user.id === session.accountId); if (!account) return null; if (shouldRefreshSession(session.updatedAt ?? session.createdAt)) { - session.updatedAt = new Date().toISOString(); - await writeSessions(sessionsFile); + await mutateSessions((latestSessionsFile) => { + const latestSession = latestSessionsFile.sessions[token]; + if (!latestSession) return latestSessionsFile; + latestSession.updatedAt = new Date().toISOString(); + return latestSessionsFile; + }); await setSessionCookie(token); } return { id: account.id, username: account.username }; @@ -199,13 +205,15 @@ export function accountSessionRoot(accountId: string, sessionId: string) { async function createSession(account: UserRecord) { const token = randomBytes(32).toString("hex"); - const sessionsFile = await readSessions(); - sessionsFile.sessions[token] = { - accountId: account.id, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - await writeSessions(sessionsFile); + const now = new Date().toISOString(); + await mutateSessions((sessionsFile) => { + sessionsFile.sessions[token] = { + accountId: account.id, + createdAt: now, + updatedAt: now, + }; + return sessionsFile; + }); await setSessionCookie(token); return { id: account.id, username: account.username }; @@ -257,7 +265,22 @@ async function readSessions(): Promise { async function writeSessions(payload: SessionsFile) { await mkdir(AUTH_STATE_ROOT, { recursive: true }); - await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8"); + const tempPath = `${SESSIONS_PATH}.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}.tmp`; + await writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8"); + await rename(tempPath, SESSIONS_PATH); +} + +async function mutateSessions( + mutator: (payload: SessionsFile) => SessionsFile | Promise, +) { + const run = sessionsWriteQueue.then(async () => { + const current = await readSessions(); + const next = await mutator(current); + await writeSessions(next); + return next; + }); + sessionsWriteQueue = run.catch(() => undefined); + return run; } function normalizeUsername(username: string) {