diff --git a/README.md b/README.md index 9a5f1b7..d258daa 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,18 @@ Non-interactive / agent automation: printf '%s' "$FATHOM_API_KEY" | fathom auth set --stdin ``` -The saved config lives at `~/.config/fathom/config.json` with `0600` permissions. +Saved auth is encrypted at rest: + +- encrypted payload: `~/.config/fathom/config.enc` +- local encryption key: `~/.config/fathom/.encryption_key` + +Permissions are kept strict: + +- config dir: `0700` +- encrypted auth file: `0600` +- encryption key file: `0600` + +`FATHOM_API_KEY` remains the highest-priority auth source and is still the safest option for ephemeral agent use. Tip: if you keep the key in a local `.env`, Node 22+ can load it without adding any CLI dependency: diff --git a/docs/CONTRACT_V1.md b/docs/CONTRACT_V1.md index 63fb043..3c8b0c5 100644 --- a/docs/CONTRACT_V1.md +++ b/docs/CONTRACT_V1.md @@ -89,12 +89,15 @@ Derived agent helpers built on top of the official API: "data": { "hasApiKey": true, "source": "env:FATHOM_API_KEY", + "storage": "env", "apiKeyRedacted": "PbXI…_WTw", "validation": { "ok": true } } } ``` +Saved local auth reports `source: "config:encrypted"` and `storage: "encrypted"`. + ### `fathom doctor --json` ```json diff --git a/package-lock.json b/package-lock.json index 25fcb4f..ed61e25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,20 @@ { "name": "fathom-video-cli", - "version": "0.1.2", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fathom-video-cli", - "version": "0.1.2", + "version": "0.1.4", "license": "MIT", "dependencies": { "commander": "^12.1.0", "yazl": "^3.3.1" }, "bin": { - "fathom": "dist/cli.js" + "fathom": "dist/cli.js", + "fathom-video-cli": "dist/cli.js" }, "devDependencies": { "@types/node": "^25.3.2", diff --git a/skills/fathom/SKILL.md b/skills/fathom/SKILL.md index 3d01b34..d21f1de 100644 --- a/skills/fathom/SKILL.md +++ b/skills/fathom/SKILL.md @@ -42,7 +42,7 @@ If `fathom doctor --json` reports missing auth: - Saved local config: `fathom auth set` - Non-interactive automation: `printf '%s' "$FATHOM_API_KEY" | fathom auth set --stdin` -Avoid pasting full keys into logs or chat. +Avoid pasting full keys into logs or chat. Prefer ephemeral `FATHOM_API_KEY` for the safest automation path. If you use `fathom auth set`, the CLI stores encrypted auth at `~/.config/fathom/config.enc` and keeps local key material in `~/.config/fathom/.encryption_key`. Public share URLs are the exception: `fathom meetings get --with transcript --json` can resolve through Fathom's public share page even when no API key is configured. diff --git a/src/cli.ts b/src/cli.ts index 270b704..0b7a006 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,7 @@ import { createRequire } from "node:module"; import { createInterface } from "node:readline/promises"; import { Command } from "commander"; -import { clearConfig, readConfig, redactApiKey, resolveApiKey } from "./config.js"; +import { clearConfig, ConfigError, getConfigPaths, readStoredConfig, redactApiKey, resolveApiKey } from "./config.js"; import { saveAndValidateApiKey, validateApiKey } from "./auth.js"; import { collectCursorPages, @@ -163,16 +163,56 @@ async function promptForApiKey(): Promise { terminal: true, }); try { - process.stderr.write(`Saving to ~/.config/fathom/config.json\n`); + process.stderr.write(`Saving encrypted auth to ~/.config/fathom/config.enc\n`); return (await rl.question("Fathom API key: ")).trim(); } finally { rl.close(); } } +async function resolveAuthState(): Promise<{ + apiKey: string | null; + source: "env:FATHOM_API_KEY" | "config:encrypted" | null; + storage: "env" | "encrypted" | "none"; + migratedFromLegacy: boolean; +}> { + const env = process.env.FATHOM_API_KEY?.trim(); + if (env) { + return { + apiKey: env, + source: "env:FATHOM_API_KEY", + storage: "env", + migratedFromLegacy: false, + }; + } + + const state = await readStoredConfig(); + return { + apiKey: state.config?.apiKey?.trim() || null, + source: state.config?.apiKey ? "config:encrypted" : null, + storage: state.storage, + migratedFromLegacy: state.migratedFromLegacy, + }; +} + +function emitAuthResolutionError(error: unknown, { json }: CommonJsonOptions): string { + const cliError = + error instanceof ConfigError + ? makeError(error, { code: error.code, message: error.message }) + : makeError(error, { code: "AUTH_INVALID", message: "Saved auth is unreadable. Run `fathom auth clear` and `fathom auth set` again." }); + if (json) printJson(fail(cliError)); + else process.stderr.write(`${cliError.message}\n`); + process.exitCode = 2; + return ""; +} + async function requireApiKey({ json }: CommonJsonOptions): Promise { - const apiKey = await resolveApiKey(); - if (apiKey) return apiKey; + try { + const apiKey = await resolveApiKey(); + if (apiKey) return apiKey; + } catch (error) { + return emitAuthResolutionError(error, { json }); + } const error = makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT }); if (json) printJson(fail(error)); @@ -288,22 +328,34 @@ program .description("Show API key source (redacted)") .option("--json", "Print JSON") .action(async (opts: CommonJsonOptions) => { - const config = await readConfig(); - const env = process.env.FATHOM_API_KEY?.trim(); - const apiKey = env || config?.apiKey || ""; + let state; + try { + state = await resolveAuthState(); + } catch (error) { + emitAuthResolutionError(error, opts); + return; + } + const apiKey = state.apiKey || ""; if (!apiKey) { - if (opts.json) printJson(fail(makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT }), { hasApiKey: false })); + if (opts.json) printJson(fail(makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT }), { hasApiKey: false, source: null, storage: "none" })); else process.stderr.write(`${AUTH_HELP_TEXT}\n`); process.exitCode = 2; return; } - const source = env ? "env:FATHOM_API_KEY" : "config"; if (opts.json) { - printJson(ok({ hasApiKey: true, source, apiKeyRedacted: redactApiKey(apiKey) })); + printJson( + ok({ + hasApiKey: true, + source: state.source, + storage: state.storage, + apiKeyRedacted: redactApiKey(apiKey), + migratedFromLegacy: state.migratedFromLegacy, + }), + ); return; } // eslint-disable-next-line no-console - console.log(`${source}: ${redactApiKey(apiKey)}`); + console.log(`${state.source}: ${redactApiKey(apiKey)}`); }), ) .addCommand( @@ -311,12 +363,24 @@ program .description("Validate the configured API key") .option("--json", "Print JSON") .action(async (opts: CommonJsonOptions) => { - const env = process.env.FATHOM_API_KEY?.trim(); - const config = await readConfig(); - const apiKey = await resolveApiKey(); - const source = env ? "env:FATHOM_API_KEY" : config?.apiKey ? "config" : null; + let state; + try { + state = await resolveAuthState(); + } catch (error) { + emitAuthResolutionError(error, opts); + return; + } + const apiKey = state.apiKey; if (!apiKey) { - if (opts.json) printJson(fail(makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT }), { hasApiKey: false, source })); + if (opts.json) { + printJson( + fail(makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT }), { + hasApiKey: false, + source: state.source, + storage: state.storage, + }), + ); + } else process.stderr.write(`${AUTH_HELP_TEXT}\n`); process.exitCode = 2; return; @@ -324,11 +388,20 @@ program const validation = await validateApiKey(apiKey); if (opts.json) { - printJson(ok({ hasApiKey: true, source, apiKeyRedacted: redactApiKey(apiKey), validation })); + printJson( + ok({ + hasApiKey: true, + source: state.source, + storage: state.storage, + apiKeyRedacted: redactApiKey(apiKey), + validation, + migratedFromLegacy: state.migratedFromLegacy, + }), + ); if (!validation.ok) process.exitCode = 1; } else if (validation.ok) { // eslint-disable-next-line no-console - console.log(`API key valid (${source || "unknown source"})`); + console.log(`API key valid (${state.source || "unknown source"})`); } else { process.stderr.write(`API key invalid: ${validation.reason}\n`); process.exitCode = 1; @@ -353,11 +426,11 @@ program const result = await saveAndValidateApiKey(raw); if (opts.json) { - printJson(ok({ saved: true, apiKeyRedacted: redactApiKey(result.apiKey), validation: result.validation })); + printJson(ok({ saved: true, storage: "encrypted", apiKeyRedacted: redactApiKey(result.apiKey), validation: result.validation })); if (!result.validation.ok) process.exitCode = 1; return; } - process.stderr.write(`Saved Fathom API key (${redactApiKey(result.apiKey)}) to ~/.config/fathom/config.json.\n`); + process.stderr.write(`Saved encrypted Fathom API key (${redactApiKey(result.apiKey)}) to ~/.config/fathom/config.enc.\n`); if (!result.validation.ok) process.exitCode = 1; }), ) @@ -366,9 +439,10 @@ program .description("Clear the saved API key from local config") .option("--json", "Print JSON") .action(async (opts: CommonJsonOptions) => { + const { encryptedConfigPath, encryptionKeyPath, legacyConfigPath } = getConfigPaths(); await clearConfig(); if (opts.json) { - printJson(ok({ cleared: true })); + printJson(ok({ cleared: true, removed: { encryptedConfigPath, encryptionKeyPath, legacyConfigPath } })); return; } // eslint-disable-next-line no-console diff --git a/src/config.ts b/src/config.ts index 44c107c..4056bf8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -6,8 +7,49 @@ export type FathomConfig = { apiKey?: string; }; -export const CONFIG_DIR = path.join(os.homedir(), ".config", "fathom"); -export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json"); +type EncryptedConfigPayload = { + v: 1; + iv: string; + tag: string; + ciphertext: string; +}; + +export type ConfigPaths = { + configDir: string; + encryptedConfigPath: string; + legacyConfigPath: string; + encryptionKeyPath: string; +}; + +export type StoredConfigState = { + config: FathomConfig | null; + storage: "encrypted" | "none"; + migratedFromLegacy: boolean; + paths: ConfigPaths; +}; + +export class ConfigError extends Error { + readonly code = "AUTH_INVALID"; + + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +function getConfigBaseDir(): string { + return process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config"); +} + +export function getConfigPaths(): ConfigPaths { + const configDir = path.join(getConfigBaseDir(), "fathom"); + return { + configDir, + encryptedConfigPath: path.join(configDir, "config.enc"), + legacyConfigPath: path.join(configDir, "config.json"), + encryptionKeyPath: path.join(configDir, ".encryption_key"), + }; +} export function redactApiKey(apiKey: string): string { const value = apiKey.trim(); @@ -16,12 +58,12 @@ export function redactApiKey(apiKey: string): string { return `${value.slice(0, 4)}…${value.slice(-4)}`; } -export async function readConfig(): Promise { +async function readJsonFile(filePath: string): Promise { try { - const raw = await fs.readFile(CONFIG_PATH, "utf8"); + const raw = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - return parsed as FathomConfig; + return parsed as T; } catch (error: any) { if (error?.code === "ENOENT") return null; throw error; @@ -29,35 +71,183 @@ export async function readConfig(): Promise { } async function ensureConfigDir(): Promise { - await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 }); - await fs.chmod(CONFIG_DIR, 0o700); + const { configDir } = getConfigPaths(); + await fs.mkdir(configDir, { recursive: true, mode: 0o700 }); + await fs.chmod(configDir, 0o700); +} + +function normalizeConfig(config: unknown): FathomConfig | null { + if (!config || typeof config !== "object" || Array.isArray(config)) return null; + const parsed = config as FathomConfig; + if (parsed.apiKey && typeof parsed.apiKey !== "string") return null; + return parsed; } -export async function writeConfig(config: FathomConfig): Promise { +async function removeIfPresent(filePath: string): Promise { + try { + await fs.unlink(filePath); + } catch (error: any) { + if (error?.code !== "ENOENT") throw error; + } +} + +async function readEncryptionKey(): Promise { + const { encryptionKeyPath } = getConfigPaths(); + try { + const raw = (await fs.readFile(encryptionKeyPath, "utf8")).trim(); + if (!raw) throw new ConfigError("Saved auth key file is empty. Run `fathom auth clear` and `fathom auth set` again."); + const key = Buffer.from(raw, "base64"); + if (key.length !== 32) { + throw new ConfigError("Saved auth key file is invalid. Run `fathom auth clear` and `fathom auth set` again."); + } + return key; + } catch (error: any) { + if (error?.code === "ENOENT") return null; + if (error instanceof ConfigError) throw error; + throw new ConfigError("Saved auth key file is unreadable. Run `fathom auth clear` and `fathom auth set` again."); + } +} + +async function getOrCreateEncryptionKey(): Promise { + const { encryptionKeyPath } = getConfigPaths(); + const existing = await readEncryptionKey(); + if (existing) return existing; + await ensureConfigDir(); - await fs.writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); - await fs.chmod(CONFIG_PATH, 0o600); + const key = randomBytes(32); + await fs.writeFile(encryptionKeyPath, `${key.toString("base64")}\n`, { mode: 0o600 }); + await fs.chmod(encryptionKeyPath, 0o600); + return key; +} + +function encryptConfig(config: FathomConfig, key: Buffer): EncryptedConfigPayload { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const plaintext = Buffer.from(JSON.stringify(config), "utf8"); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + v: 1, + iv: iv.toString("base64"), + tag: tag.toString("base64"), + ciphertext: ciphertext.toString("base64"), + }; +} + +function decryptConfig(payload: EncryptedConfigPayload, key: Buffer): FathomConfig { + if (payload.v !== 1 || !payload.iv || !payload.tag || !payload.ciphertext) { + throw new ConfigError("Saved auth file is invalid. Run `fathom auth clear` and `fathom auth set` again."); + } + try { + const iv = Buffer.from(payload.iv, "base64"); + const tag = Buffer.from(payload.tag, "base64"); + const ciphertext = Buffer.from(payload.ciphertext, "base64"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + const parsed = normalizeConfig(JSON.parse(plaintext)); + if (!parsed) { + throw new ConfigError("Saved auth payload is invalid. Run `fathom auth clear` and `fathom auth set` again."); + } + return parsed; + } catch (error) { + if (error instanceof ConfigError) throw error; + throw new ConfigError("Saved auth is unreadable. Run `fathom auth clear` and `fathom auth set` again."); + } +} + +async function readEncryptedConfig(): Promise { + const { encryptedConfigPath } = getConfigPaths(); + let payload: EncryptedConfigPayload | null = null; + try { + const raw = await fs.readFile(encryptedConfigPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ConfigError("Saved auth file is invalid. Run `fathom auth clear` and `fathom auth set` again."); + } + payload = parsed as EncryptedConfigPayload; + } catch (error: any) { + if (error?.code === "ENOENT") return null; + if (error instanceof ConfigError) throw error; + throw new ConfigError("Saved auth file is unreadable. Run `fathom auth clear` and `fathom auth set` again."); + } + const key = await readEncryptionKey(); + if (!key) { + throw new ConfigError("Saved auth key is missing. Run `fathom auth clear` and `fathom auth set` again."); + } + return decryptConfig(payload, key); +} + +async function writeEncryptedConfig(config: FathomConfig): Promise { + const { encryptedConfigPath, legacyConfigPath } = getConfigPaths(); + await ensureConfigDir(); + const key = await getOrCreateEncryptionKey(); + const payload = encryptConfig(config, key); + await fs.writeFile(encryptedConfigPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); + await fs.chmod(encryptedConfigPath, 0o600); + await removeIfPresent(legacyConfigPath); +} + +async function readLegacyConfig(): Promise { + const { legacyConfigPath } = getConfigPaths(); + const parsed = await readJsonFile(legacyConfigPath); + return normalizeConfig(parsed); +} + +export async function readStoredConfig(): Promise { + const paths = getConfigPaths(); + const encryptedConfig = await readEncryptedConfig(); + if (encryptedConfig) { + await removeIfPresent(paths.legacyConfigPath); + return { + config: encryptedConfig, + storage: "encrypted", + migratedFromLegacy: false, + paths, + }; + } + + const legacyConfig = await readLegacyConfig(); + if (legacyConfig?.apiKey?.trim()) { + await writeEncryptedConfig({ apiKey: legacyConfig.apiKey.trim() }); + await removeIfPresent(paths.legacyConfigPath); + return { + config: { apiKey: legacyConfig.apiKey.trim() }, + storage: "encrypted", + migratedFromLegacy: true, + paths, + }; + } + + return { + config: null, + storage: "none", + migratedFromLegacy: false, + paths, + }; +} + +export async function readConfig(): Promise { + return (await readStoredConfig()).config; } export async function saveApiKey(apiKey: string): Promise { const normalized = apiKey.trim(); if (!normalized) throw new Error("API key is empty"); - await writeConfig({ apiKey: normalized }); + await writeEncryptedConfig({ apiKey: normalized }); return normalized; } export async function clearConfig(): Promise { - try { - await fs.unlink(CONFIG_PATH); - } catch (error: any) { - if (error?.code !== "ENOENT") throw error; - } + const { encryptedConfigPath, legacyConfigPath, encryptionKeyPath } = getConfigPaths(); + await removeIfPresent(encryptedConfigPath); + await removeIfPresent(legacyConfigPath); + await removeIfPresent(encryptionKeyPath); } export async function resolveApiKey(): Promise { const env = process.env.FATHOM_API_KEY?.trim(); if (env) return env; - const config = await readConfig(); - return config?.apiKey?.trim() || null; + const state = await readStoredConfig(); + return state.config?.apiKey?.trim() || null; } - diff --git a/test/cli-auth.test.ts b/test/cli-auth.test.ts new file mode 100644 index 0000000..7668d7e --- /dev/null +++ b/test/cli-auth.test.ts @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { getConfigPaths, saveApiKey } from "../src/config.js"; + +const execFileAsync = promisify(execFile); +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, ".."); +const cliPath = path.join(repoRoot, "dist", "cli.js"); +const fetchMockPath = path.join(repoRoot, "test", "helpers", "mock-fetch-auth-ok.mjs"); + +async function withTempConfigHome(fn: (tempDir: string) => Promise): Promise { + const previousXdg = process.env.XDG_CONFIG_HOME; + const previousApiKey = process.env.FATHOM_API_KEY; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "fathom-cli-auth-test-")); + process.env.XDG_CONFIG_HOME = tempDir; + delete process.env.FATHOM_API_KEY; + try { + await fn(tempDir); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdg; + if (previousApiKey === undefined) delete process.env.FATHOM_API_KEY; + else process.env.FATHOM_API_KEY = previousApiKey; + } +} + +async function runCli(args: string[], env: NodeJS.ProcessEnv) { + return execFileAsync("node", ["--import", fetchMockPath, cliPath, ...args], { + cwd: repoRoot, + env: { + ...process.env, + ...env, + }, + }); +} + +test("auth status reports env-based auth in JSON mode", async () => { + await withTempConfigHome(async (tempDir) => { + const { stdout } = await runCli(["auth", "status", "--json"], { + XDG_CONFIG_HOME: tempDir, + FATHOM_API_KEY: "env-secret-123456", + }); + const parsed = JSON.parse(stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.data.source, "env:FATHOM_API_KEY"); + assert.equal(parsed.data.storage, "env"); + assert.equal(parsed.data.apiKeyRedacted, "env-…3456"); + assert.equal(parsed.data.validation.ok, true); + }); +}); + +test("auth show reports redacted encrypted saved auth in JSON mode", async () => { + await withTempConfigHome(async (tempDir) => { + await saveApiKey("saved-secret-123456"); + const { stdout } = await runCli(["auth", "show", "--json"], { + XDG_CONFIG_HOME: tempDir, + }); + const parsed = JSON.parse(stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.data.hasApiKey, true); + assert.equal(parsed.data.source, "config:encrypted"); + assert.equal(parsed.data.storage, "encrypted"); + assert.equal(parsed.data.apiKeyRedacted, "save…3456"); + }); +}); + +test("auth status reports encrypted saved auth in JSON mode", async () => { + await withTempConfigHome(async (tempDir) => { + await saveApiKey("saved-secret-123456"); + const { stdout } = await runCli(["auth", "status", "--json"], { + XDG_CONFIG_HOME: tempDir, + }); + const parsed = JSON.parse(stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.data.source, "config:encrypted"); + assert.equal(parsed.data.storage, "encrypted"); + assert.equal(parsed.data.apiKeyRedacted, "save…3456"); + assert.equal(parsed.data.validation.ok, true); + }); +}); + +test("auth status migrates legacy plaintext config and reports encrypted storage", async () => { + await withTempConfigHome(async (tempDir) => { + const { configDir, legacyConfigPath, encryptedConfigPath } = getConfigPaths(); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(legacyConfigPath, `${JSON.stringify({ apiKey: "legacy-secret-123456" }, null, 2)}\n`, "utf8"); + + const { stdout } = await runCli(["auth", "status", "--json"], { + XDG_CONFIG_HOME: tempDir, + }); + + const parsed = JSON.parse(stdout); + assert.equal(parsed.ok, true); + assert.equal(parsed.data.source, "config:encrypted"); + assert.equal(parsed.data.storage, "encrypted"); + assert.equal(parsed.data.migratedFromLegacy, true); + await assert.rejects(fs.access(legacyConfigPath)); + await fs.access(encryptedConfigPath); + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts index 08351c5..8913928 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,8 +1,97 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { redactApiKey } from "../src/config.js"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { ConfigError, clearConfig, getConfigPaths, readStoredConfig, redactApiKey, resolveApiKey, saveApiKey } from "../src/config.js"; + +async function withTempConfigHome(fn: (tempDir: string) => Promise): Promise { + const previousXdg = process.env.XDG_CONFIG_HOME; + const previousApiKey = process.env.FATHOM_API_KEY; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "fathom-config-test-")); + process.env.XDG_CONFIG_HOME = tempDir; + delete process.env.FATHOM_API_KEY; + try { + await fn(tempDir); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdg; + if (previousApiKey === undefined) delete process.env.FATHOM_API_KEY; + else process.env.FATHOM_API_KEY = previousApiKey; + } +} test("redactApiKey keeps only a small prefix and suffix", () => { assert.equal(redactApiKey("abcd1234wxyz9876"), "abcd…9876"); }); +test("saveApiKey stores encrypted config and not plaintext", async () => { + await withTempConfigHome(async () => { + await saveApiKey("demo-secret-123456"); + const { encryptedConfigPath, encryptionKeyPath, legacyConfigPath } = getConfigPaths(); + const encryptedRaw = await fs.readFile(encryptedConfigPath, "utf8"); + const keyRaw = await fs.readFile(encryptionKeyPath, "utf8"); + + assert.match(encryptedRaw, /"ciphertext":/); + assert.doesNotMatch(encryptedRaw, /demo-secret-123456/); + assert.ok(keyRaw.trim().length > 0); + await assert.rejects(fs.access(legacyConfigPath)); + + const state = await readStoredConfig(); + assert.equal(state.storage, "encrypted"); + assert.equal(state.config?.apiKey, "demo-secret-123456"); + assert.equal(await resolveApiKey(), "demo-secret-123456"); + }); +}); + +test("FATHOM_API_KEY overrides saved encrypted auth", async () => { + await withTempConfigHome(async () => { + await saveApiKey("saved-secret-123456"); + process.env.FATHOM_API_KEY = "env-secret-654321"; + assert.equal(await resolveApiKey(), "env-secret-654321"); + }); +}); + +test("clearConfig removes encrypted auth files and key material", async () => { + await withTempConfigHome(async () => { + await saveApiKey("demo-secret-123456"); + const { encryptedConfigPath, encryptionKeyPath } = getConfigPaths(); + await clearConfig(); + await assert.rejects(fs.access(encryptedConfigPath)); + await assert.rejects(fs.access(encryptionKeyPath)); + assert.equal(await resolveApiKey(), null); + }); +}); + +test("legacy plaintext config migrates automatically to encrypted storage", async () => { + await withTempConfigHome(async () => { + const { configDir, legacyConfigPath, encryptedConfigPath } = getConfigPaths(); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(legacyConfigPath, `${JSON.stringify({ apiKey: "legacy-secret-123456" }, null, 2)}\n`, "utf8"); + + const state = await readStoredConfig(); + assert.equal(state.migratedFromLegacy, true); + assert.equal(state.storage, "encrypted"); + assert.equal(state.config?.apiKey, "legacy-secret-123456"); + await assert.rejects(fs.access(legacyConfigPath)); + await fs.access(encryptedConfigPath); + }); +}); + +test("corrupted encrypted config raises a clean config error", async () => { + await withTempConfigHome(async () => { + const { configDir, encryptedConfigPath, encryptionKeyPath } = getConfigPaths(); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(encryptedConfigPath, `{"v":1,"iv":"bad","tag":"bad","ciphertext":"bad"}\n`, "utf8"); + await fs.writeFile(encryptionKeyPath, Buffer.alloc(32, 7).toString("base64"), "utf8"); + + await assert.rejects( + () => readStoredConfig(), + (error: unknown) => + error instanceof ConfigError && + error.code === "AUTH_INVALID" && + /Saved auth is unreadable|Saved auth payload is invalid|Saved auth file is invalid/.test(error.message), + ); + }); +}); diff --git a/test/helpers/mock-fetch-auth-ok.mjs b/test/helpers/mock-fetch-auth-ok.mjs new file mode 100644 index 0000000..cb8502b --- /dev/null +++ b/test/helpers/mock-fetch-auth-ok.mjs @@ -0,0 +1,11 @@ +globalThis.fetch = async (url) => { + const parsed = new URL(typeof url === "string" ? url : url.url); + if (parsed.pathname === "/external/v1/teams") { + return new Response(JSON.stringify({ items: [{ name: "Operations", created_at: "2026-03-01T00:00:00.000Z" }], next_cursor: null, limit: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + throw new Error(`Unexpected mocked fetch URL: ${parsed.toString()}`); +};