import { promises as fs } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createLogger } from './logger.js'; const log = createLogger('tesla-token-store'); export interface TeslaTokens { accessToken: string; refreshToken: string; expiresAt: number; // ms epoch scope: string; // Optional cached vehicle metadata so we don't have to refetch on every call. vehicleId?: string; // numeric id_s used in Fleet API URLs vin?: string; carType?: string; // e.g. modely trimBadging?: string; // e.g. lrawd connectedAt: number; } interface Store { get(userId: string): Promise; set(userId: string, tokens: TeslaTokens): Promise; remove(userId: string): Promise; } // File-backed store. Falls back to /app/data when running under Dokku (we // will mount a Dokku storage volume on /app/data), or to ./data in dev. const __dirname = path.dirname(fileURLToPath(import.meta.url)); const dataDir = process.env.TESLA_TOKEN_DIR || (process.env.NODE_ENV === 'production' ? '/app/data' : path.resolve(__dirname, '../../data')); const tokenFile = path.join(dataDir, 'tesla-tokens.json'); let cache: Record = {}; let loaded = false; let writeLock: Promise = Promise.resolve(); async function load(): Promise { if (loaded) return; try { await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }); await fs.chmod(dataDir, 0o700).catch(() => {}); // tighten if it already existed const raw = await fs.readFile(tokenFile, 'utf8'); cache = JSON.parse(raw); log.info({ file: tokenFile, users: Object.keys(cache).length }, 'Loaded Tesla tokens'); } catch (err: any) { if (err.code !== 'ENOENT') log.warn({ err: String(err) }, 'Failed to load Tesla tokens — starting empty'); cache = {}; } loaded = true; } async function persist(): Promise { // Serialise writes so concurrent set/remove calls don't race. Write to a // tmpfile then rename for atomicity; owner-only perms via mode + chmod. writeLock = writeLock .then(async () => { await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }); await fs.chmod(dataDir, 0o700).catch(() => {}); const tmp = `${tokenFile}.${process.pid}.tmp`; await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 }); await fs.chmod(tmp, 0o600).catch(() => {}); await fs.rename(tmp, tokenFile); await fs.chmod(tokenFile, 0o600).catch(() => {}); }) .catch(err => log.error({ err: String(err) }, 'Failed to persist Tesla tokens')); await writeLock; } export const teslaTokenStore: Store = { async get(userId) { await load(); return cache[userId] || null; }, async set(userId, tokens) { await load(); cache[userId] = tokens; await persist(); }, async remove(userId) { await load(); delete cache[userId]; await persist(); }, };