import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { resolveSpotifyPaths, type ResolveConfigOptions } from "./config.js"; import type { SpotifyToken } from "./types.js"; export interface TokenStoreOptions extends ResolveConfigOptions { tokenPath?: string; } function isSpotifyToken(value: unknown): value is SpotifyToken { return Boolean( value && typeof value === "object" && typeof (value as SpotifyToken).accessToken === "string" && typeof (value as SpotifyToken).refreshToken === "string" && typeof (value as SpotifyToken).expiresAt === "number" ); } export async function resolveTokenPath(options: TokenStoreOptions = {}): Promise { if (options.tokenPath) { return options.tokenPath; } return (await resolveSpotifyPaths(options)).tokenPath; } export async function loadToken(options: TokenStoreOptions = {}): Promise { const tokenPath = await resolveTokenPath(options); let raw: string; try { raw = await readFile(tokenPath, "utf8"); } catch { return undefined; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error("Spotify token store is not valid JSON."); } if (!isSpotifyToken(parsed)) { throw new Error("Spotify token store has an invalid token shape."); } return parsed; } export async function saveToken(token: SpotifyToken, options: TokenStoreOptions = {}): Promise { const tokenPath = await resolveTokenPath(options); await mkdir(dirname(tokenPath), { recursive: true, mode: 0o700 }); const tempPath = `${tokenPath}.${process.pid}.${Date.now()}.tmp`; await writeFile(tempPath, `${JSON.stringify(token, null, 2)}\n`, { mode: 0o600 }); try { await chmod(tempPath, 0o600); } catch { // chmod can fail on non-POSIX filesystems; the initial mode still keeps normal macOS writes private. } await rename(tempPath, tokenPath); try { await chmod(tokenPath, 0o600); } catch { // Best effort only; token values are never printed. } return tokenPath; } export function tokenNeedsRefresh(token: SpotifyToken, now = Date.now(), skewMs = 60_000): boolean { return token.expiresAt <= now + skewMs; } export async function tokenFileMode(options: TokenStoreOptions = {}): Promise { const tokenPath = await resolveTokenPath(options); try { return (await stat(tokenPath)).mode & 0o777; } catch { return undefined; } }