feat(spotify): implement milestone M2 auth

This commit is contained in:
2026-04-12 01:36:27 -05:00
parent f7dfb7d71d
commit c8c0876b7c
8 changed files with 670 additions and 5 deletions
+80
View File
@@ -0,0 +1,80 @@
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<string> {
if (options.tokenPath) {
return options.tokenPath;
}
return (await resolveSpotifyPaths(options)).tokenPath;
}
export async function loadToken(options: TokenStoreOptions = {}): Promise<SpotifyToken | undefined> {
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<string> {
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<number | undefined> {
const tokenPath = await resolveTokenPath(options);
try {
return (await stat(tokenPath)).mode & 0o777;
} catch {
return undefined;
}
}