import { createHash, randomUUID } from "node:crypto" import fs from "node:fs" import os from "node:os" import path from "node:path" import { z } from "zod" import { env } from "./env" const SAVED_SESSION_SCHEMA = z.object({ origin: z.url(), executionDataProfileId: z.string().optional(), token: z.string(), }) /** * Returns the origin-specific path used for the current CLI session. * * @param appOrigin - Application origin whose session path is requested. */ export function getSessionPath(appOrigin = env.APP_ORIGIN) { return path.join( os.homedir(), ".automate/sessions", `${createHash("sha256") .update(new URL(appOrigin).origin) .digest("base64url") .slice(0, 16)}.json`, ) } /** Reads and validates the saved CLI session token for the current origin. */ export function loadSavedTokenSync() { const origin = new URL(env.APP_ORIGIN).origin try { const session = loadSavedSessionSync(origin) return session.origin === origin ? session.token : null } catch { return null } } /** * Persists the session token for the current origin. * * @param token - Bearer token to save. */ export function saveTokenSync(token: string) { const origin = new URL(env.APP_ORIGIN).origin saveSessionTokenAtPathSync(getSessionPath(origin), origin, token) } /** * Persists a newly authenticated token without inheriting another login's data. * * @param token - Bearer token returned by an explicit login flow. */ export function saveNewLoginTokenSync(token: string) { const origin = new URL(env.APP_ORIGIN).origin saveSessionTokenAtPathSync(getSessionPath(origin), origin, token, { preserveExecutionDataProfile: false, }) } /** * Atomically rotates a token while preserving its local data association. * * @param sessionPath - Origin-specific session file to replace. * @param origin - Application origin recorded with the session. * @param token - New bearer token. * @param options - Whether a bearer rotation retains the authorized profile. * @param options.preserveExecutionDataProfile - Keep the current profile * association. Explicit login flows must disable this. */ export function saveSessionTokenAtPathSync( sessionPath: string, origin: string, token: string, options: { preserveExecutionDataProfile?: boolean } = {}, ) { let executionDataProfileId: string | undefined if (options.preserveExecutionDataProfile !== false) { try { const session = SAVED_SESSION_SCHEMA.parse( JSON.parse(fs.readFileSync(sessionPath, "utf8")), ) if (session.origin === origin) { executionDataProfileId = session.executionDataProfileId } } catch { // A missing or invalid prior session is replaced by the new login. } } fs.mkdirSync(path.dirname(sessionPath), { mode: 0o700, recursive: true }) fs.chmodSync(path.dirname(sessionPath), 0o700) atomicWritePrivateFileSync( sessionPath, JSON.stringify( { origin, token, ...(executionDataProfileId && { executionDataProfileId }), }, null, 2, ), ) } /** * Associates the current login session with its authorized local data profile. * * @param profileId - Opaque server-issued local profile identity. */ export function saveExecutionDataProfileSync(profileId: string) { const origin = new URL(env.APP_ORIGIN).origin const sessionPath = getSessionPath(origin) fs.chmodSync(path.dirname(sessionPath), 0o700) atomicWritePrivateFileSync( sessionPath, JSON.stringify( { ...loadSavedSessionSync(origin), executionDataProfileId: profileId }, null, 2, ), ) } /** Reads the local data profile authorized by the current login session. */ export function loadExecutionDataProfileSync() { const origin = new URL(env.APP_ORIGIN).origin try { const session = loadSavedSessionSync(origin) return session.origin === origin ? (session.executionDataProfileId ?? null) : null } catch { return null } } /** Removes the saved CLI session for the current origin. */ export function clearSavedTokenSync() { fs.rmSync(getSessionPath(), { force: true }) } /** * Reads and validates the saved session for one application origin. * * @param origin - Application origin whose session should be loaded. */ function loadSavedSessionSync(origin: string) { return SAVED_SESSION_SCHEMA.parse( JSON.parse(fs.readFileSync(getSessionPath(origin), "utf-8")), ) } /** * Atomically replaces a sensitive file with owner-only permissions. * * @param filePath - Destination file to replace. * @param contents - Complete file contents. * @throws When writing, hardening, or replacing the file fails. */ function atomicWritePrivateFileSync(filePath: string, contents: string) { const temporaryPath = path.join( path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`, ) try { fs.writeFileSync(temporaryPath, contents, { mode: 0o600 }) fs.chmodSync(temporaryPath, 0o600) fs.renameSync(temporaryPath, filePath) } catch (error) { fs.rmSync(temporaryPath, { force: true }) throw error } }