/** * Chain resolver for HeyGen credentials. * * Priority — first non-empty wins: * 1. `HEYGEN_API_KEY` env (matches heygen-cli) * 2. `HYPERFRAMES_API_KEY` env (alias for parity with other tools) * 3. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key * * Absent sources fall through. A broken file (parse error, bad shape) * surfaces immediately as `ErrInvalidStore` — silently falling back * would mask user config bugs. * * Expiry policy: an OAuth access_token whose `expires_at` is in the * past (60s skew) is considered expired. If a `refresh_token` is also * present, callers can still use it via `refreshable: true`. Otherwise * the api_key (if any) wins. */ import { isHeaderSafe, readStore } from "./store.js"; import { ErrInvalidStore, ErrNotConfigured, isAuthError } from "./errors.js"; type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy"; interface ApiKeyCredential { type: "api_key"; key: string; source: CredentialSource; } interface OAuthCredential { type: "oauth"; access_token: string; refresh_token?: string; expires_at?: Date; scope?: string; source: CredentialSource; /** True when the access_token is expired but a refresh_token exists. */ refreshable: boolean; } export type ResolvedCredential = ApiKeyCredential | OAuthCredential; const EXPIRY_SKEW_MS = 60 * 1000; export interface ResolveOptions { now?: () => Date; } export async function resolveCredential(opts: ResolveOptions = {}): Promise { const now = (opts.now ?? (() => new Date()))(); const heygenEnv = process.env["HEYGEN_API_KEY"]; if (heygenEnv && heygenEnv.length > 0) { if (!isHeaderSafe(heygenEnv)) { throw ErrInvalidStore("HEYGEN_API_KEY contains control characters"); } return { type: "api_key", key: heygenEnv, source: "env" }; } const hfEnv = process.env["HYPERFRAMES_API_KEY"]; if (hfEnv && hfEnv.length > 0) { if (!isHeaderSafe(hfEnv)) { throw ErrInvalidStore("HYPERFRAMES_API_KEY contains control characters"); } return { type: "api_key", key: hfEnv, source: "env_alias" }; } const { credentials, source } = await readStore(); if (source === "absent") throw ErrNotConfigured(); const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json"; if (credentials.oauth) { const oauth = pickOAuth(credentials.oauth, now, fileSource); if (oauth) return oauth; } if (credentials.api_key) { return { type: "api_key", key: credentials.api_key, source: fileSource }; } throw ErrNotConfigured(); } /** Like `resolveCredential` but returns `null` instead of throwing `NOT_CONFIGURED`. */ export async function tryResolveCredential( opts: ResolveOptions = {}, ): Promise { try { return await resolveCredential(opts); } catch (err) { if (isAuthError(err) && err.code === "NOT_CONFIGURED") { return null; } throw err; } } function pickOAuth( tokens: NonNullable>["credentials"]["oauth"]>, now: Date, source: CredentialSource, ): OAuthCredential | null { const expiresAt = parseDate(tokens.expires_at); const expired = expiresAt !== undefined && expiresAt.getTime() - EXPIRY_SKEW_MS < now.getTime(); if (expired && !tokens.refresh_token) return null; const out: OAuthCredential = { type: "oauth", access_token: tokens.access_token, source, refreshable: expired && tokens.refresh_token !== undefined, }; if (tokens.refresh_token) out.refresh_token = tokens.refresh_token; if (expiresAt) out.expires_at = expiresAt; if (tokens.scope) out.scope = tokens.scope; return out; } function parseDate(s: string | undefined): Date | undefined { if (!s) return undefined; const d = new Date(s); return Number.isNaN(d.getTime()) ? undefined : d; }