fix(cli): address code-review findings on auth PR

This commit is contained in:
James
2026-05-28 05:39:24 +00:00
parent 9a7b3efa94
commit 8a9291c434
19 changed files with 1974 additions and 0 deletions
+57
View File
@@ -860,6 +860,63 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
</Tab>
</Tabs>
## hyperframes auth
Sign in to HeyGen and manage credentials. Credentials are stored in
`~/.heygen/credentials` (mode `0600`) and are **shared with the
`heygen` CLI** — sign in with one and the other picks up the session.
Resolution order (first match wins):
1. `HEYGEN_API_KEY` environment variable
2. `HYPERFRAMES_API_KEY` environment variable (hyperframes alias)
3. `~/.heygen/credentials`
### Subcommands
#### `auth login --api-key`
Save a HeyGen API key. The key is verified against `GET /v3/users/me`
before the command reports success; a rejected key is not left on disk.
```bash
# Interactive hidden-input prompt
hyperframes auth login --api-key
# Pipe a key from stdin (CI-friendly)
echo "$HEYGEN_API_KEY" | hyperframes auth login --api-key
```
#### `auth status`
Show the active credential's source, type, and verified identity
(account + billing snapshot). Exits non-zero when nothing is configured
or the API rejects the credential, so scripts can check sign-in state.
```bash
hyperframes auth status
hyperframes auth status --json # machine-readable
```
#### `auth logout`
Remove the stored credential. Prompts for confirmation on a TTY.
```bash
hyperframes auth logout
hyperframes auth logout --keep-api-key # clear only an OAuth session
hyperframes auth logout --yes # skip the confirmation prompt
```
### Environment variables
| Variable | Description |
|----------|-------------|
| `HEYGEN_API_KEY` | Override the stored credential. |
| `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. |
| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). |
| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). |
## hyperframes lambda
Deploy HyperFrames distributed rendering to AWS Lambda and drive renders from your laptop or CI.
+181
View File
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthClient, apiBaseUrl, buildAuthHeaders } from "./client.js";
import { isAuthError } from "./errors.js";
import type { ResolvedCredential } from "./resolver.js";
function jsonFetch(body: unknown, status = 200): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
}
function textFetch(body: string, status: number): typeof fetch {
return (async () => new Response(body, { status })) as unknown as typeof fetch;
}
function apiKeyCred(): ResolvedCredential {
return { type: "api_key", key: "hg_x", source: "env" };
}
function makeClient(fetchImpl: typeof fetch): AuthClient {
return new AuthClient({ baseUrl: "https://api.test.example", fetchImpl });
}
describe("auth/client", () => {
const original = process.env["HEYGEN_API_URL"];
beforeEach(() => {
delete process.env["HEYGEN_API_URL"];
});
afterEach(() => {
if (original !== undefined) process.env["HEYGEN_API_URL"] = original;
else delete process.env["HEYGEN_API_URL"];
});
it("apiBaseUrl defaults to https://api.heygen.com", () => {
expect(apiBaseUrl()).toBe("https://api.heygen.com");
});
it("apiBaseUrl honors HEYGEN_API_URL and strips trailing slash", () => {
process.env["HEYGEN_API_URL"] = "https://api.dev.heygen.com/";
expect(apiBaseUrl()).toBe("https://api.dev.heygen.com");
});
it("buildAuthHeaders uses Bearer for oauth", () => {
const cred: ResolvedCredential = {
type: "oauth",
access_token: "at_123",
source: "file_json",
refreshable: false,
};
expect(buildAuthHeaders(cred)).toEqual({ authorization: "Bearer at_123" });
});
it("buildAuthHeaders uses x-api-key for api_key", () => {
expect(buildAuthHeaders(apiKeyCred())).toEqual({ "x-api-key": "hg_x" });
});
it("getCurrentUser parses a wrapped {data: {...}} payload", async () => {
const client = makeClient(
jsonFetch({
code: 100,
message: "ok",
data: {
username: "alice",
email: "alice@example.com",
billing_type: "subscription",
subscription: {
plan: "team",
credits: {
premium_credits: { remaining: 4200, resets_at: "2026-12-01T00:00:00Z" },
add_on_credits: { remaining: 9 },
},
},
},
}),
);
const user = await client.getCurrentUser(apiKeyCred());
expect(user.username).toBe("alice");
expect(user.email).toBe("alice@example.com");
expect(user.subscription?.plan).toBe("team");
expect(user.subscription?.credits?.premium_credits?.remaining).toBe(4200);
expect(user.subscription?.credits?.premium_credits?.resets_at).toBe("2026-12-01T00:00:00Z");
expect(user.subscription?.credits?.add_on_credits?.remaining).toBe(9);
});
it("getCurrentUser parses an unwrapped payload", async () => {
const client = makeClient(jsonFetch({ email: "bob@example.com" }));
const user = await client.getCurrentUser(apiKeyCred());
expect(user.email).toBe("bob@example.com");
});
it("getCurrentUser throws ErrUnauthenticated on 401", async () => {
const client = makeClient(textFetch("invalid token", 401));
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
});
});
it("getCurrentUser throws ErrApi on 5xx", async () => {
const client = makeClient(textFetch("upstream", 503));
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
});
});
it("getCurrentUser throws ErrApi when 2xx body is not valid JSON", async () => {
const fetchImpl = (async () =>
new Response("<html>not json</html>", {
status: 200,
headers: { "content-type": "text/html" },
})) as unknown as typeof fetch;
const client = makeClient(fetchImpl);
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
});
});
it("getCurrentUser returns empty UserInfo when payload.data is an array", async () => {
const client = makeClient(jsonFetch({ code: 0, data: [{ email: "x@y" }] }));
const user = await client.getCurrentUser(apiKeyCred());
expect(user).toEqual({});
});
it("getCurrentUser scrubs hg_ keys and JWTs from 401 detail", async () => {
const fetchImpl = textFetch(
'invalid request — got header "x-api-key: hg_supersecret_abc123"',
401,
);
const client = makeClient(fetchImpl);
try {
await client.getCurrentUser(apiKeyCred());
} catch (err) {
const msg = (err as Error).message;
expect(msg).not.toContain("hg_supersecret_abc123");
expect(msg).toContain("<redacted>");
return;
}
throw new Error("expected rejection");
});
it("getCurrentUser redacts the full Authorization: Bearer value (not just the scheme)", async () => {
const fetchImpl = textFetch(
"rejected — echoed Authorization: Bearer at_opaque_secret_999",
401,
);
const client = makeClient(fetchImpl);
try {
await client.getCurrentUser(apiKeyCred());
} catch (err) {
const msg = (err as Error).message;
expect(msg).not.toContain("at_opaque_secret_999");
expect(msg).not.toContain("Bearer at_opaque_secret_999");
expect(msg).toContain("<redacted>");
return;
}
throw new Error("expected rejection");
});
it("getCurrentUser sends the right header for oauth credentials", async () => {
let captured: Record<string, string> = {};
const fetchImpl = (async (_url: string, init?: RequestInit) => {
captured = (init?.headers as Record<string, string>) ?? {};
return new Response(JSON.stringify({ email: "alice@example.com" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;
const client = makeClient(fetchImpl);
await client.getCurrentUser({
type: "oauth",
access_token: "at_xyz",
source: "file_json",
refreshable: false,
});
expect(captured["authorization"]).toBe("Bearer at_xyz");
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* Minimal typed HTTP client for HeyGen endpoints needed by the auth
* commands. Hand-written rather than codegen'd because the surface is
* one endpoint (`/v3/users/me`) and pulling in an OpenAPI pipeline is
* disproportionate.
*
* Reads `HEYGEN_API_URL` (default `https://api.heygen.com`) so dev
* testing is one env var away.
*
* Auth header selection:
* - OAuth → `Authorization: Bearer <token>`
* - API key → `x-api-key: <key>`
*
* The backend `/v3/users/me` accepts both. See
* `movio/api_service/app/controller/user_v3.py`.
*/
import { ErrApi, ErrUnauthenticated } from "./errors.js";
import type { ResolvedCredential } from "./resolver.js";
const DEFAULT_BASE_URL = "https://api.heygen.com";
export function apiBaseUrl(): string {
const override = process.env["HEYGEN_API_URL"];
return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL;
}
export type BillingType = "wallet" | "subscription" | "usage_based" | string;
export interface WalletInfo {
currency?: string;
remaining_balance?: number;
auto_reload?: boolean;
}
/**
* The API returns `credits.{premium,add_on}_credits` as nested objects
* (`{ remaining, resets_at? }`), not bare numbers — discovered live
* against api.heygen.com. Modelling them as nested objects so the row
* formatter can render them properly instead of `[object Object]`.
*/
export interface CreditBalance {
remaining?: number;
resets_at?: string;
}
export interface SubscriptionInfo {
plan?: string;
credits?: {
premium_credits?: CreditBalance;
add_on_credits?: CreditBalance;
};
}
export interface UsageBasedInfo {
spending_current_usd?: number;
spending_cap_usd?: number;
}
/** Subset of the backend response we surface to users today. */
export interface UserInfo {
username?: string;
email?: string;
first_name?: string;
last_name?: string;
billing_type?: BillingType;
wallet?: WalletInfo;
subscription?: SubscriptionInfo;
usage_based?: UsageBasedInfo;
}
export interface AuthClientOptions {
/** Override base URL (otherwise `HEYGEN_API_URL` / default). */
baseUrl?: string;
/** Inject a custom fetch (used by tests). */
fetchImpl?: typeof fetch;
}
export class AuthClient {
private readonly base: string;
private readonly fetchImpl: typeof fetch;
constructor(opts: AuthClientOptions = {}) {
this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, "");
this.fetchImpl = opts.fetchImpl ?? fetch;
}
/**
* `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi`
* on any other non-2xx or non-JSON body.
*/
async getCurrentUser(credential: ResolvedCredential): Promise<UserInfo> {
const url = `${this.base}/v3/users/me`;
const headers = buildAuthHeaders(credential);
const res = await this.fetchImpl(url, { method: "GET", headers });
if (res.status === 401) {
const detail = await safeText(res);
throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`);
}
if (!res.ok) {
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
}
let payload: unknown;
try {
payload = await res.json();
} catch (err) {
throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`);
}
return extractUserInfo(payload);
}
}
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
if (credential.type === "oauth") {
return { authorization: `Bearer ${credential.access_token}` };
}
return { "x-api-key": credential.key };
}
async function safeText(res: Response): Promise<string> {
try {
const body = (await res.text()).slice(0, 500);
return scrubCredentials(body);
} catch {
return "";
}
}
/**
* Strip credential-shaped substrings from error bodies before they
* surface in user-facing messages or `--json` output. Some proxies
* echo request headers in their error pages and we never want a
* HeyGen API key, OAuth bearer, or JWT to land in scrollback / CI
* logs because of one of those echoes.
*/
function scrubCredentials(s: string): string {
return (
s
.replace(/hg_[A-Za-z0-9_-]{4,}/g, "hg_<redacted>")
// Redact the ENTIRE header value to end-of-line — `Bearer <token>`
// is two whitespace-separated words, so a `\S+` would leave the
// opaque token exposed after the scheme.
.replace(/(authorization|x-api-key)[ \t]*[:=][ \t]*[^\r\n]+/gi, "$1: <redacted>")
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<jwt-redacted>")
);
}
/**
* The backend wraps responses in `{code, message, data: {...}}` for some
* endpoints and returns raw fields directly for others. Handle both.
*/
function extractUserInfo(payload: unknown): UserInfo {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
const obj = payload as Record<string, unknown>;
const wrapped = obj["data"];
const data =
wrapped && typeof wrapped === "object" && !Array.isArray(wrapped)
? (wrapped as Record<string, unknown>)
: obj;
return {
username: pickString(data, "username"),
email: pickString(data, "email"),
first_name: pickString(data, "first_name"),
last_name: pickString(data, "last_name"),
billing_type: pickString(data, "billing_type"),
wallet: pickObject(data, "wallet") as WalletInfo | undefined,
subscription: pickObject(data, "subscription") as SubscriptionInfo | undefined,
usage_based: pickObject(data, "usage_based") as UsageBasedInfo | undefined,
};
}
function pickString(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" ? v : undefined;
}
function pickObject(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const v = obj[key];
return v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: undefined;
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
AuthError,
ErrApi,
ErrInvalidStore,
ErrNotConfigured,
ErrUnauthenticated,
isAuthError,
} from "./errors.js";
describe("auth/errors", () => {
it("ErrNotConfigured carries the right code + hint", () => {
const err = ErrNotConfigured();
expect(err).toBeInstanceOf(AuthError);
expect(err.code).toBe("NOT_CONFIGURED");
expect(err.hint).toContain("hyperframes auth login");
});
it("ErrInvalidStore wraps the detail", () => {
const err = ErrInvalidStore("malformed at line 3");
expect(err.code).toBe("INVALID_STORE");
expect(err.message).toContain("malformed at line 3");
});
it("ErrUnauthenticated includes detail when provided", () => {
expect(ErrUnauthenticated().code).toBe("UNAUTHENTICATED");
expect(ErrUnauthenticated("invalid token").message).toContain("invalid token");
});
it("ErrApi captures status + detail", () => {
const err = ErrApi(503, "upstream timeout");
expect(err.code).toBe("API_ERROR");
expect(err.message).toContain("503");
expect(err.message).toContain("upstream timeout");
});
it("isAuthError narrows properly", () => {
expect(isAuthError(ErrNotConfigured())).toBe(true);
expect(isAuthError(new Error("plain"))).toBe(false);
expect(isAuthError(null)).toBe(false);
expect(isAuthError("string")).toBe(false);
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Typed errors for the auth layer. Callers branch on `code` so commands
* can map specific failures to friendly UX without parsing messages.
*/
export type AuthErrorCode = "NOT_CONFIGURED" | "INVALID_STORE" | "API_ERROR" | "UNAUTHENTICATED";
export class AuthError extends Error {
readonly code: AuthErrorCode;
readonly hint?: string;
constructor(code: AuthErrorCode, message: string, hint?: string) {
super(message);
this.name = "AuthError";
this.code = code;
this.hint = hint;
}
}
export const ErrNotConfigured = () =>
new AuthError(
"NOT_CONFIGURED",
"No HeyGen credentials found",
"Run `hyperframes auth login` to sign in.",
);
export const ErrInvalidStore = (detail: string) =>
new AuthError(
"INVALID_STORE",
`Credential file is unreadable: ${detail}`,
"Delete ~/.heygen/credentials and run `hyperframes auth login` to re-create it.",
);
export const ErrUnauthenticated = (detail?: string) =>
new AuthError(
"UNAUTHENTICATED",
detail ? `HeyGen rejected the credential: ${detail}` : "HeyGen rejected the credential",
"Run `hyperframes auth login` to re-authenticate.",
);
export const ErrApi = (status: number, detail: string) =>
new AuthError("API_ERROR", `HeyGen API error (${status}): ${detail}`);
export function isAuthError(err: unknown): err is AuthError {
return err instanceof AuthError;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Public surface of the auth library — only the symbols the auth
* commands consume today. Internal types stay in their source files.
*/
export { isAuthError } from "./errors.js";
export { clearOAuth, deleteStore, isHeaderSafe, readStore, writeStore } from "./store.js";
export type { Credentials } from "./store.js";
export { configDir, credentialPath } from "./paths.js";
export { tryResolveCredential } from "./resolver.js";
export type { ResolvedCredential } from "./resolver.js";
export { AuthClient } from "./client.js";
export type { UserInfo } from "./client.js";
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
import { CREDENTIAL_FILENAME, configDir, credentialPath } from "./paths.js";
describe("auth/paths", () => {
const original = process.env["HEYGEN_CONFIG_DIR"];
beforeEach(() => {
delete process.env["HEYGEN_CONFIG_DIR"];
});
afterEach(() => {
if (original !== undefined) process.env["HEYGEN_CONFIG_DIR"] = original;
else delete process.env["HEYGEN_CONFIG_DIR"];
});
it("defaults to ~/.heygen", () => {
expect(configDir()).toBe(join(homedir(), ".heygen"));
});
it("honors HEYGEN_CONFIG_DIR override", () => {
process.env["HEYGEN_CONFIG_DIR"] = "/tmp/some-test-dir";
expect(configDir()).toBe("/tmp/some-test-dir");
expect(credentialPath()).toBe(join("/tmp/some-test-dir", CREDENTIAL_FILENAME));
});
it("treats empty HEYGEN_CONFIG_DIR as unset", () => {
process.env["HEYGEN_CONFIG_DIR"] = "";
expect(configDir()).toBe(join(homedir(), ".heygen"));
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Filesystem layout for the shared HeyGen credential store. Mirrors
* `heygen-cli/internal/paths/paths.go` so both CLIs read the same file.
* `HEYGEN_CONFIG_DIR` overrides the directory.
*/
import { homedir } from "node:os";
import { join } from "node:path";
/**
* Filename for the credential store. Matches heygen-cli (no `.json`
* suffix) so a `~/.heygen/credentials` written by either CLI is
* readable by the other — see `heygen-cli/internal/auth/file_resolver.go`.
*/
export const CREDENTIAL_FILENAME = "credentials";
export function configDir(): string {
const override = process.env["HEYGEN_CONFIG_DIR"];
if (override && override.length > 0) return override;
return join(homedir(), ".heygen");
}
export function credentialPath(): string {
return join(configDir(), CREDENTIAL_FILENAME);
}
+158
View File
@@ -0,0 +1,158 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isAuthError } from "./errors.js";
import { resolveCredential, tryResolveCredential } from "./resolver.js";
import { writeStore } from "./store.js";
async function makeTmpDir(): Promise<string> {
return fs.mkdtemp(join(tmpdir(), "hf-auth-resolve-"));
}
const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const;
describe("auth/resolver", () => {
let dir: string;
const saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
beforeEach(async () => {
dir = await makeTmpDir();
for (const k of ENV_KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
process.env["HEYGEN_CONFIG_DIR"] = dir;
});
afterEach(async () => {
for (const k of ENV_KEYS) {
const v = saved[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
await fs.rm(dir, { recursive: true, force: true });
});
it("prefers HEYGEN_API_KEY over everything else", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r).toEqual({ type: "api_key", key: "env-key", source: "env" });
});
it("falls through to HYPERFRAMES_API_KEY", async () => {
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r).toEqual({ type: "api_key", key: "alias-key", source: "env_alias" });
});
it("returns file api_key when no env is set", async () => {
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") {
expect(r.key).toBe("file-key");
expect(r.source).toBe("file_json");
}
});
it("prefers fresh oauth over api_key", async () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
await writeStore({
api_key: "file-key",
oauth: { access_token: "fresh-at", refresh_token: "rt", expires_at: future },
});
const r = await resolveCredential();
expect(r.type).toBe("oauth");
if (r.type === "oauth") {
expect(r.access_token).toBe("fresh-at");
// Fresh access_token does NOT need refresh, even with refresh_token present.
expect(r.refreshable).toBe(false);
}
});
it("oauth without expires_at is treated as fresh (refreshable=false)", async () => {
await writeStore({
oauth: { access_token: "at", refresh_token: "rt" },
});
const r = await resolveCredential();
expect(r.type).toBe("oauth");
if (r.type === "oauth") expect(r.refreshable).toBe(false);
});
it("marks expired-but-refreshable oauth as refreshable", async () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
oauth: { access_token: "stale-at", refresh_token: "rt", expires_at: past },
});
const r = await resolveCredential();
expect(r.type).toBe("oauth");
if (r.type === "oauth") expect(r.refreshable).toBe(true);
});
it("skips expired non-refreshable oauth and falls through to api_key", async () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
api_key: "fallback",
oauth: { access_token: "stale-at", expires_at: past },
});
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") expect(r.key).toBe("fallback");
});
it("rejects HEYGEN_API_KEY containing CRLF (header-injection guard)", async () => {
process.env["HEYGEN_API_KEY"] = "hg_x\r\nX-Evil: 1";
await expect(resolveCredential()).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "INVALID_STORE";
});
});
it("throws ErrNotConfigured when nothing is configured", async () => {
await expect(resolveCredential()).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "NOT_CONFIGURED";
});
});
it("identifies legacy plaintext file source", async () => {
const path = join(dir, "credentials");
await fs.writeFile(path, "hg_legacy_key", { mode: 0o600 });
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") {
expect(r.key).toBe("hg_legacy_key");
expect(r.source).toBe("file_legacy");
}
});
it("tryResolveCredential returns null when not configured", async () => {
expect(await tryResolveCredential()).toBeNull();
});
it("tryResolveCredential surfaces broken-file errors", async () => {
const path = join(dir, "credentials");
await fs.writeFile(path, "{not valid", { mode: 0o600 });
await expect(tryResolveCredential()).rejects.toSatisfy((err) => isAuthError(err));
});
it("uses injected now() for expiry decisions", async () => {
// expires_at is one hour ago in real time. Injecting `now` two
// hours in the past makes the token appear fresh (still valid for
// another hour), so the resolver should NOT mark it refreshable.
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
oauth: { access_token: "at", refresh_token: "rt", expires_at: oneHourAgo },
});
const r = await resolveCredential({
now: () => new Date(Date.now() - 2 * 60 * 60 * 1000),
});
expect(r.type).toBe("oauth");
if (r.type === "oauth") {
expect(r.access_token).toBe("at");
expect(r.refreshable).toBe(false);
}
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* 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<ResolvedCredential> {
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<ResolvedCredential | null> {
try {
return await resolveCredential(opts);
} catch (err) {
if (isAuthError(err) && err.code === "NOT_CONFIGURED") {
return null;
}
throw err;
}
}
function pickOAuth(
tokens: NonNullable<Awaited<ReturnType<typeof readStore>>["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;
}
+209
View File
@@ -0,0 +1,209 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isAuthError } from "./errors.js";
import { clearOAuth, deleteStore, readStore, writeStore, type Credentials } from "./store.js";
async function makeTmpDir(): Promise<string> {
return fs.mkdtemp(join(tmpdir(), "hf-auth-store-"));
}
// POSIX file modes don't apply on Windows — `fs.chmod` only toggles the
// read-only bit there, so `stat.mode & 0o777` reports 0o666/0o444
// regardless of what we requested. Skip the mode assertions on win32;
// the 0600/0700 hardening is a Unix concern.
const IS_POSIX = process.platform !== "win32";
describe("auth/store", () => {
let dir: string;
let path: string;
beforeEach(async () => {
dir = await makeTmpDir();
path = join(dir, "credentials");
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("returns absent when the file does not exist", async () => {
const result = await readStore(path);
expect(result).toEqual({ credentials: {}, source: "absent" });
});
it("round-trips api_key only", async () => {
const creds: Credentials = { api_key: "hg_test_abc" };
await writeStore(creds, path);
const result = await readStore(path);
expect(result.source).toBe("file_json");
expect(result.credentials).toEqual(creds);
});
it("round-trips oauth tokens", async () => {
const creds: Credentials = {
oauth: {
access_token: "at_123",
refresh_token: "rt_456",
expires_at: "2026-06-25T12:00:00.000Z",
scope: "openid profile",
token_type: "Bearer",
},
};
await writeStore(creds, path);
const result = await readStore(path);
expect(result.credentials).toEqual(creds);
});
it("round-trips both api_key and oauth", async () => {
const creds: Credentials = {
api_key: "hg_test_abc",
oauth: { access_token: "at_123" },
};
await writeStore(creds, path);
const result = await readStore(path);
expect(result.credentials.api_key).toBe("hg_test_abc");
expect(result.credentials.oauth?.access_token).toBe("at_123");
});
it("reads legacy one-line plaintext format", async () => {
await fs.writeFile(path, "hg_legacy_key\n", { mode: 0o600 });
const result = await readStore(path);
expect(result.source).toBe("file_legacy");
expect(result.credentials.api_key).toBe("hg_legacy_key");
});
it("treats empty file as absent", async () => {
await fs.writeFile(path, "", { mode: 0o600 });
const result = await readStore(path);
expect(result.source).toBe("absent");
});
it("throws ErrInvalidStore on garbage JSON", async () => {
await fs.writeFile(path, "{not valid json", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("throws ErrInvalidStore on multi-line non-JSON content", async () => {
await fs.writeFile(path, "not\na\nkey", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it.skipIf(!IS_POSIX)("writes file 0600 and dir 0700", async () => {
const nested = join(dir, "sub", "deeper");
const p = join(nested, "credentials");
await writeStore({ api_key: "hg_x" }, p);
expect((await fs.stat(p)).mode & 0o777).toBe(0o600);
expect((await fs.stat(nested)).mode & 0o777).toBe(0o700);
});
it("preserves content across overwrites", async () => {
await writeStore({ api_key: "first" }, path);
await writeStore({ api_key: "second" }, path);
if (IS_POSIX) {
expect((await fs.stat(path)).mode & 0o777).toBe(0o600);
}
const result = await readStore(path);
expect(result.credentials.api_key).toBe("second");
});
it("rejects empty-string api_key", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "" }), { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("rejects api_key with CR/LF (header-injection guard)", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x\r\nX-Evil: foo" }), { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("strips oauth fields containing CR/LF rather than crashing later", async () => {
await fs.writeFile(
path,
JSON.stringify({
oauth: {
access_token: "good_at",
refresh_token: "bad_rt\r\nX-Smuggle: 1",
},
}),
{ mode: 0o600 },
);
const result = await readStore(path);
expect(result.credentials.oauth?.access_token).toBe("good_at");
expect(result.credentials.oauth?.refresh_token).toBeUndefined();
});
it("rejects access_token containing CR/LF (header-injection guard)", async () => {
await fs.writeFile(path, JSON.stringify({ oauth: { access_token: "at\r\nX-Evil: 1" } }), {
mode: 0o600,
});
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("accepts a legacy plaintext key of any HeyGen key format", async () => {
// Real HeyGen keys come in multiple formats (`sk_V2_…`, `hg_…`,
// partner keys, etc.). The CLI doesn't shape-check — the backend
// does. Any single-line printable non-empty value is accepted as
// a legacy key here; the next /v3/users/me call decides validity.
await fs.writeFile(path, "sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN", {
mode: 0o600,
});
const result = await readStore(path);
expect(result.source).toBe("file_legacy");
expect(result.credentials.api_key).toBe(
"sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN",
);
});
it("still rejects plaintext that contains a space (not a credential shape)", async () => {
await fs.writeFile(path, "hello world this is not a key", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("still rejects too-short plaintext", async () => {
await fs.writeFile(path, "tiny", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("rejects oauth without access_token", async () => {
await fs.writeFile(path, JSON.stringify({ oauth: { refresh_token: "rt" } }), {
mode: 0o600,
});
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("drops unknown top-level keys", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: { stuff: 1 } }), {
mode: 0o600,
});
const result = await readStore(path);
expect(result.credentials).toEqual({ api_key: "hg_x" });
});
it("deleteStore is idempotent", async () => {
await writeStore({ api_key: "hg_x" }, path);
await deleteStore(path);
await deleteStore(path);
await expect(fs.access(path)).rejects.toThrow();
});
it("clearOAuth removes only the oauth field", async () => {
await writeStore({ api_key: "hg_keep", oauth: { access_token: "drop_me" } }, path);
await clearOAuth(path);
const result = await readStore(path);
expect(result.credentials.oauth).toBeUndefined();
expect(result.credentials.api_key).toBe("hg_keep");
});
it("clearOAuth removes the whole file when no api_key remains", async () => {
await writeStore({ oauth: { access_token: "only" } }, path);
await clearOAuth(path);
await expect(fs.access(path)).rejects.toThrow();
});
it("clearOAuth is a no-op when file is absent", async () => {
await clearOAuth(path);
await expect(fs.access(path)).rejects.toThrow();
});
});
+255
View File
@@ -0,0 +1,255 @@
/**
* Read/write the shared `~/.heygen/credentials` file (JSON contents,
* no `.json` extension — the path matches heygen-cli).
*
* Current format:
* {
* "api_key": "hg_...",
* "oauth": {
* "access_token": "...",
* "refresh_token": "...",
* "expires_at": "2026-06-25T12:00:00Z",
* "scope": "openid profile",
* "token_type": "Bearer"
* }
* }
*
* Legacy: a single-line plaintext API key (the format heygen-cli has
* written historically). If `JSON.parse` rejects the file, we treat the
* trimmed contents as an API key; the next write upgrades to JSON.
*
* Writes go to a temp file + rename, 0600 mode, parent dir 0700.
*/
import { promises as fs } from "node:fs";
import { dirname } from "node:path";
import { credentialPath } from "./paths.js";
import { ErrInvalidStore } from "./errors.js";
const FILE_MODE = 0o600;
const DIR_MODE = 0o700;
export interface OAuthTokens {
access_token: string;
refresh_token?: string;
/** ISO-8601 UTC. */
expires_at?: string;
scope?: string;
token_type?: string;
}
export interface Credentials {
api_key?: string;
oauth?: OAuthTokens;
}
export type StoreSource = "file_json" | "file_legacy" | "absent";
export interface ReadResult {
credentials: Credentials;
source: StoreSource;
}
export async function readStore(path = credentialPath()): Promise<ReadResult> {
let raw: string;
try {
raw = await fs.readFile(path, "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return { credentials: {}, source: "absent" };
}
throw ErrInvalidStore(`unable to read ${path}: ${(err as Error).message}`);
}
const trimmed = raw.trim();
if (trimmed.length === 0) return { credentials: {}, source: "absent" };
if (trimmed.startsWith("{")) {
return { credentials: parseJsonStore(trimmed), source: "file_json" };
}
if (looksLikeApiKey(trimmed)) {
return { credentials: { api_key: trimmed }, source: "file_legacy" };
}
throw ErrInvalidStore("file is not JSON and does not look like a plain API key");
}
export async function writeStore(credentials: Credentials, path = credentialPath()): Promise<void> {
await ensureDir(dirname(path));
const body = JSON.stringify(serializeCredentials(credentials), null, 2);
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tmp, `${body}\n`, { mode: FILE_MODE, encoding: "utf8" });
// `mode` on `writeFile` is masked by umask and only applies on file
// creation — explicit chmod is the only reliable way to land on 0600.
// `rename` moves the (already-0600) tmp inode over the destination,
// so the final file carries the tmp's mode; no post-rename chmod
// needed even when overwriting a looser-permissioned file.
await fs.chmod(tmp, FILE_MODE);
await fs.rename(tmp, path);
}
export async function deleteStore(path = credentialPath()): Promise<void> {
try {
await fs.unlink(path);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
throw err;
}
}
/** Remove only the `oauth` block. Used by `auth logout --keep-api-key`. */
export async function clearOAuth(path = credentialPath()): Promise<void> {
const { credentials, source } = await readStore(path);
if (source === "absent" || !credentials.oauth) return;
if (!credentials.api_key) {
await deleteStore(path);
return;
}
await writeStore({ api_key: credentials.api_key }, path);
}
async function ensureDir(dir: string): Promise<void> {
try {
const stat = await fs.stat(dir);
if (!stat.isDirectory()) {
throw ErrInvalidStore(`${dir} exists and is not a directory`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
await fs.mkdir(dir, { recursive: true, mode: DIR_MODE });
}
try {
await fs.chmod(dir, DIR_MODE);
} catch {
/* perm-less filesystems are fine */
}
}
function parseJsonStore(text: string): Credentials {
const obj = parseJsonObject(text, "credential file root");
const out: Credentials = {};
const apiKey = pickRequiredStringOrAbsent(obj, "api_key", "api_key");
if (apiKey !== undefined) {
if (!isHeaderSafe(apiKey)) {
throw ErrInvalidStore("api_key must not contain control characters");
}
out.api_key = apiKey;
}
if (obj["oauth"] !== undefined && obj["oauth"] !== null) {
out.oauth = parseOAuth(obj["oauth"]);
}
return out;
}
function parseOAuth(raw: unknown): OAuthTokens {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw ErrInvalidStore("oauth must be a JSON object");
}
const obj = raw as Record<string, unknown>;
const accessToken = pickHeaderSafeString(obj, "access_token");
if (!accessToken) {
throw ErrInvalidStore("oauth.access_token must be a non-empty string with no control chars");
}
const out: OAuthTokens = { access_token: accessToken };
const refresh = pickHeaderSafeString(obj, "refresh_token");
if (refresh) out.refresh_token = refresh;
const exp = pickNonEmptyString(obj, "expires_at");
if (exp) out.expires_at = exp;
const scope = pickNonEmptyString(obj, "scope");
if (scope) out.scope = scope;
const tokenType = pickNonEmptyString(obj, "token_type");
if (tokenType) out.token_type = tokenType;
return out;
}
function parseJsonObject(text: string, label: string): Record<string, unknown> {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch (err) {
throw ErrInvalidStore(`invalid JSON: ${(err as Error).message}`);
}
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw ErrInvalidStore(`${label} must be a JSON object`);
}
return raw as Record<string, unknown>;
}
function pickNonEmptyString(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" && v.length > 0 ? v : undefined;
}
/** Like `pickNonEmptyString` but rejects values containing control chars. */
function pickHeaderSafeString(obj: Record<string, unknown>, key: string): string | undefined {
const v = pickNonEmptyString(obj, key);
return v !== undefined && isHeaderSafe(v) ? v : undefined;
}
/**
* Header-safety check for credential strings: reject any string with
* CR, LF, NUL, or other C0 control characters. Without this, a
* malicious credentials.json could smuggle extra request headers via
* `Authorization` / `x-api-key` (RFC 7230 header injection).
*/
export function isHeaderSafe(s: string): boolean {
// Reject U+0000-U+001F (C0 controls) and U+007F (DEL) — bytes that
// aren't allowed in HTTP header values. Using charCodeAt avoids
// embedding control characters in regex source (lint requirement).
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 0x20 || c === 0x7f) return false;
}
return true;
}
/**
* Strict variant: returns the string when present and non-empty,
* `undefined` when the key is absent or null, and throws when the
* field is present-but-invalid (wrong type or empty string).
*/
function pickRequiredStringOrAbsent(
obj: Record<string, unknown>,
key: string,
errorLabel: string,
): string | undefined {
const v = obj[key];
if (v === undefined || v === null) return undefined;
if (typeof v !== "string" || v.length === 0) {
throw ErrInvalidStore(`${errorLabel} must be a non-empty string`);
}
return v;
}
function serializeCredentials(c: Credentials): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (c.api_key) out["api_key"] = c.api_key;
if (c.oauth) {
const oauth: Record<string, unknown> = { access_token: c.oauth.access_token };
if (c.oauth.refresh_token) oauth["refresh_token"] = c.oauth.refresh_token;
if (c.oauth.expires_at) oauth["expires_at"] = c.oauth.expires_at;
if (c.oauth.scope) oauth["scope"] = c.oauth.scope;
if (c.oauth.token_type) oauth["token_type"] = c.oauth.token_type;
out["oauth"] = oauth;
}
return out;
}
/**
* Legacy-plaintext heuristic. HeyGen API keys come in multiple formats
* (`sk_V2_…`, historic `hg_…`, partner keys, etc.) and the CLI should
* NOT shape-check them — the backend's `/v3/users/me` is the source of
* truth and the existing `auth login` rollback handles bad keys cleanly.
* We only require: a single line, printable, of reasonable length, and
* header-safe (no CR/LF). JSON files are detected separately by the
* leading `{`, so this path can't swallow a JSON fragment.
*/
function looksLikeApiKey(s: string): boolean {
if (s.length < 8) return false;
if (!isHeaderSafe(s)) return false;
// Single line of printable ASCII (excluding space, since real keys
// don't contain spaces — a space-bearing blob is almost certainly
// not a credential).
return /^[!-~]+$/.test(s);
}
+1
View File
@@ -124,6 +124,7 @@ const subCommands = {
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
capture: () => import("./commands/capture.js").then((m) => m.default),
lambda: () => import("./commands/lambda.js").then((m) => m.default),
auth: () => import("./commands/auth.js").then((m) => m.default),
};
const main = defineCommand({
+53
View File
@@ -0,0 +1,53 @@
/**
* `hyperframes auth` — credential management for HeyGen.
*
* Subverbs:
* - `login` sign in via API key (OAuth coming next)
* - `status` show the active credential + identity
* - `logout` remove the stored credential
*
* Each subverb lives in `./auth/<name>.ts` and is dynamic-imported on
* demand. Keeps cold-start fast and lets the auth library load only
* when the user is doing auth work.
*/
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
export const examples: Example[] = [
["Save an API key (interactive)", "hyperframes auth login --api-key"],
["Save an API key from stdin", "echo $HEYGEN_API_KEY | hyperframes auth login --api-key"],
["Check who you're signed in as", "hyperframes auth status"],
["Sign out", "hyperframes auth logout"],
];
const HELP = `
${c.bold("hyperframes auth")} ${c.dim("<subcommand> [args]")}
Manage HeyGen credentials. Credentials live in
${c.accent("~/.heygen/credentials")} and are shared with heygen-cli.
${c.bold("SUBCOMMANDS:")}
${c.accent("login")} ${c.dim("Save a HeyGen API key (--api-key). OAuth login lands in a follow-up.")}
${c.accent("status")} ${c.dim("Show the active credential's source, type, and identity.")}
${c.accent("logout")} ${c.dim("Remove the stored credential (--keep-api-key for OAuth-only).")}
${c.bold("ENV VARS:")}
${c.accent("HEYGEN_API_KEY")} Override the stored credential.
${c.accent("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen).
`;
export default defineCommand({
meta: { name: "auth", description: "Sign in to HeyGen and manage credentials" },
subCommands: {
login: () => import("./auth/login.js").then((m) => m.default),
status: () => import("./auth/status.js").then((m) => m.default),
logout: () => import("./auth/logout.js").then((m) => m.default),
},
async run({ args }) {
if (!args._?.[0]) console.log(HELP);
},
});
@@ -0,0 +1,91 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readStore, writeStore } from "../../auth/store.js";
// Mock only AuthClient — keep the real store/resolver so the test
// exercises the actual on-disk rollback behavior. `verifyResult`
// controls what `getCurrentUser` does per test.
const verifyState = vi.hoisted(() => ({ reject: false }));
vi.mock("../../auth/index.js", async (orig) => {
const actual = await orig<typeof import("../../auth/index.js")>();
class MockAuthClient {
async getCurrentUser(): Promise<{ email: string }> {
if (verifyState.reject) {
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
throw rej("invalid key");
}
return { email: "alice@example.com" };
}
}
return { ...actual, AuthClient: MockAuthClient };
});
const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const;
describe("auth login --api-key rollback", () => {
let dir: string;
const saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
beforeEach(async () => {
dir = await fs.mkdtemp(join(tmpdir(), "hf-login-"));
for (const k of ENV_KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
process.env["HEYGEN_CONFIG_DIR"] = dir;
verifyState.reject = false;
// process.exit throws so we can assert the post-rollback state.
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
for (const k of ENV_KEYS) {
const v = saved[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
await fs.rm(dir, { recursive: true, force: true });
});
async function runLogin(apiKey: string): Promise<void> {
const cmd = (await import("./login.js")).default;
// citty command run only reads `args` here.
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
args: { "api-key": apiKey },
});
}
it("removes the rejected key on a failed FIRST login (no prior credential)", async () => {
verifyState.reject = true;
await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/);
// The store must NOT retain the rejected key — otherwise the next
// command would silently resolve a known-bad credential.
const { source } = await readStore();
expect(source).toBe("absent");
});
it("restores the previous credential on a failed re-login", async () => {
await writeStore({ api_key: "hg_previous_good" });
verifyState.reject = true;
await expect(runLogin("hg_newbadkey99")).rejects.toThrow(/process\.exit:1/);
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_previous_good");
});
it("persists the key on a successful login", async () => {
verifyState.reject = false;
await runLogin("hg_goodkey456");
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_goodkey456");
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* `hyperframes auth login` — write a HeyGen credential to
* `~/.heygen/credentials`.
*
* This first cut ships the `--api-key` path only. Running `auth login`
* without `--api-key` prints a pointer at the OAuth PKCE work that
* lands in a follow-up.
*
* Inputs:
* - `--api-key=<value>` — take the value inline (note: may leak into
* shell history).
* - `--api-key` with stdin piped — read one line from stdin.
* - `--api-key` interactive — `@clack/prompts` password input.
*
* Write semantics:
* - Read the existing credential file first; preserve any `oauth`
* block so saving a new API key doesn't wipe an OAuth session.
* - Sanity-check that the input is non-empty and header-safe (no
* CR/LF) before touching disk. The backend's `/v3/users/me` is
* the source of truth for whether the key is actually valid —
* we do NOT shape-check the prefix (real keys come in multiple
* formats: `sk_V2_…`, `hg_…`, partner keys, etc.).
* - Verify via `GET /v3/users/me`. On 401, roll back to the previous
* state — leaving a confirmed-invalid key on disk would silently
* break subsequent commands. On other errors (network blip, 5xx)
* keep the new key so retries don't require re-typing.
*/
import { defineCommand } from "citty";
import { stdin as input } from "node:process";
import {
AuthClient,
deleteStore,
isAuthError,
isHeaderSafe,
readStore,
writeStore,
type Credentials,
} from "../../auth/index.js";
import { c } from "../../ui/colors.js";
const STDIN_TIMEOUT_MS = 30_000;
// Smallest plausible length for a real API key. We don't validate the
// prefix or character set — the backend's /v3/users/me is the source
// of truth and rolls back on rejection. The only must-check is
// header-safety (CR/LF), which `isHeaderSafe` covers.
const MIN_KEY_LENGTH = 8;
export default defineCommand({
meta: {
name: "login",
description: "Sign in to HeyGen by saving an API key (OAuth coming soon)",
},
args: {
"api-key": {
type: "string",
description:
"API key value. Pass `--api-key` with no value to read from stdin or interactively.",
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const inlineKey = args["api-key"];
if (inlineKey === undefined) {
printOAuthPlaceholder();
process.exit(1);
}
const key = await collectApiKey(inlineKey);
if (!key) {
console.error(c.error("No API key provided."));
process.exit(1);
}
if (!isHeaderSafe(key)) {
// CR/LF in the value would smuggle headers when the key is sent
// via `x-api-key`. The backend handles "wrong key" itself, but
// header-injection has to be caught here.
console.error(c.error("API key must not contain newline or control characters."));
process.exit(1);
}
if (key.length < MIN_KEY_LENGTH) {
console.error(c.error(`API key looks too short (got ${key.length} chars).`));
process.exit(1);
}
const previous = await snapshotStore();
const next: Credentials = { ...previous, api_key: key };
await writeStore(next);
const verifyOk = await verifyAndReport(key);
if (!verifyOk) {
await rollback(previous);
process.exit(1);
}
},
});
function printOAuthPlaceholder(): void {
console.error(
`${c.warn("Browser-based login isn't ready yet.")} ` +
`Re-run with ${c.accent("--api-key")} to save an API key, ` +
`or pipe one in:\n` +
` ${c.accent("echo $HEYGEN_API_KEY | hyperframes auth login --api-key")}`,
);
}
async function snapshotStore(): Promise<Credentials> {
try {
const { credentials } = await readStore();
return { ...credentials };
} catch {
// Existing file is unreadable; treat as empty so the new key still
// lands cleanly. The previous bytes are lost either way.
return {};
}
}
async function rollback(previous: Credentials): Promise<void> {
try {
if (previous.api_key || previous.oauth) {
await writeStore(previous);
console.error(c.dim("Rolled back to the previous credential."));
} else {
// No prior credential — restore true absence. Leaving the
// rejected key on disk would make the next `auth status` /
// command silently resolve a known-bad key.
await deleteStore();
console.error(c.dim("Removed the rejected credential."));
}
} catch (err) {
console.error(c.error(`Failed to roll back: ${(err as Error).message}`));
}
}
/**
* Returns `true` on successful verify, `false` on a 401. Other errors
* (network blip, 5xx) bubble out — the caller leaves the new key in
* place since the issue is transient.
*/
// fallow-ignore-next-line complexity
async function verifyAndReport(key: string): Promise<boolean> {
const client = new AuthClient();
try {
const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" });
const identity = user.email ?? user.username ?? "(unknown user)";
console.log(c.success(`✓ API key saved. Authenticated as ${identity}.`));
return true;
} catch (err) {
if (isAuthError(err) && err.code === "UNAUTHENTICATED") {
console.error(
`${c.warn("HeyGen rejected the API key.")}\n` +
` ${c.dim(err.message)}\n` +
`Run ${c.accent("hyperframes auth login --api-key")} again with a valid key.`,
);
return false;
}
throw err;
}
}
/**
* Citty's arg type for `--api-key` is `string`, so:
* - `--api-key=hg_x` → `"hg_x"`
* - `--api-key ""` / `--api-key` with no value → `""` → fall through
* to stdin/prompt.
*/
async function collectApiKey(inline: string): Promise<string> {
if (inline.length > 0) return inline.trim();
if (!input.isTTY) {
return (await readAllWithTimeout(input, STDIN_TIMEOUT_MS)).trim();
}
return await promptForKey();
}
/**
* Read all of stdin, or bail with an empty string after `timeoutMs`.
* Hanging forever when stdin is non-TTY but unattached (Docker `-d`,
* some CI shells) is worse than a clear timeout.
*/
async function readAllWithTimeout(
stream: NodeJS.ReadableStream,
timeoutMs: number,
): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
const timer = setTimeout(() => {
reject(new Error(`Timed out waiting for stdin (${timeoutMs}ms). Pipe the key explicitly.`));
}, timeoutMs);
stream.on("data", (chunk: Buffer | string) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
stream.on("end", () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString("utf8"));
});
stream.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
}
async function promptForKey(): Promise<string> {
const clack = await import("@clack/prompts");
const value = await clack.password({
message: "Enter HeyGen API key",
validate: (v) => {
if (!v || v.length < MIN_KEY_LENGTH) return "API key looks too short";
if (!isHeaderSafe(v)) return "API key must not contain newline or control characters";
return undefined;
},
});
if (clack.isCancel(value)) {
console.error("Aborted.");
process.exit(1);
}
return value.trim();
}
+74
View File
@@ -0,0 +1,74 @@
/**
* `hyperframes auth logout` remove the credential file. With
* `--keep-api-key`, only the OAuth block is cleared (no-op for
* API-key-only stores).
*
* Env-only credentials (`HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`) can't
* be cleared by this command we tell the user to unset them.
*/
import { defineCommand } from "citty";
import { clearOAuth, configDir, credentialPath, deleteStore } from "../../auth/index.js";
import { c } from "../../ui/colors.js";
export default defineCommand({
meta: { name: "logout", description: "Remove the stored HeyGen credential" },
args: {
"keep-api-key": {
type: "boolean",
description: "Only clear the OAuth session; preserve the API key.",
default: false,
},
yes: {
type: "boolean",
description: "Skip the confirmation prompt.",
default: false,
},
},
async run({ args }) {
warnIfEnvCredentialActive();
const keepApiKey = Boolean(args["keep-api-key"]);
if (!(await ensureConfirmed(Boolean(args.yes), keepApiKey))) {
console.log("Aborted.");
process.exit(1);
}
if (keepApiKey) {
await clearOAuth();
console.log(c.success("✓ OAuth session removed. API key retained."));
return;
}
await deleteStore();
console.log(c.success(`✓ Signed out. Removed ${credentialPath()}.`));
},
});
function warnIfEnvCredentialActive(): void {
if (process.env["HEYGEN_API_KEY"] || process.env["HYPERFRAMES_API_KEY"]) {
console.log(
c.warn(
"An env-var credential is active. Unset HEYGEN_API_KEY / HYPERFRAMES_API_KEY to remove it.",
),
);
}
}
async function ensureConfirmed(yes: boolean, keepApiKey: boolean): Promise<boolean> {
if (yes) return true;
const prompt = keepApiKey
? `This will sign out of any active OAuth session on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `
: `This will sign out of HeyGen on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `;
return confirmInteractive(prompt);
}
async function confirmInteractive(prompt: string): Promise<boolean> {
if (!process.stdin.isTTY) return false;
const { createInterface } = await import("node:readline");
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) => {
rl.question(prompt, (line) => resolve(line));
});
rl.close();
return /^y(es)?$/i.test(answer.trim());
}
+200
View File
@@ -0,0 +1,200 @@
/**
* `hyperframes auth status` print the active credential's source,
* type, and identity (verified against `GET /v3/users/me`).
*
* Exits non-zero when nothing is configured or the API rejects the
* credential, so scripts can check "am I logged in?" with `$?`.
*/
import { defineCommand } from "citty";
import {
AuthClient,
isAuthError,
tryResolveCredential,
type ResolvedCredential,
type UserInfo,
} from "../../auth/index.js";
import { c } from "../../ui/colors.js";
interface VerifiedStatus {
credential: ResolvedCredential;
user: UserInfo | null;
apiError: string | null;
}
export default defineCommand({
meta: { name: "status", description: "Show the active HeyGen credential" },
args: {
json: {
type: "boolean",
description: "Emit machine-readable JSON",
default: false,
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const asJson = Boolean(args.json);
let credential;
try {
credential = await tryResolveCredential();
} catch (err) {
handleResolveError(err, asJson);
return;
}
if (!credential) {
handleUnconfigured(asJson);
return;
}
const status = await verify(credential);
if (asJson) printJsonStatus(status);
else printHumanStatus(status);
process.exit(status.apiError ? 1 : 0);
},
});
function handleUnconfigured(asJson: boolean): never {
if (asJson) {
console.log(JSON.stringify({ configured: false }));
} else {
console.log(c.warn("Not signed in to HeyGen."));
console.log(`Run ${c.accent("hyperframes auth login --api-key")} to sign in.`);
}
process.exit(1);
}
// fallow-ignore-next-line complexity
function handleResolveError(err: unknown, asJson: boolean): never {
if (!isAuthError(err)) throw err;
if (asJson) {
console.log(JSON.stringify({ configured: false, error: err.message, hint: err.hint ?? null }));
} else {
console.error(c.error(err.message));
if (err.hint) console.error(c.dim(err.hint));
}
process.exit(1);
}
async function verify(credential: ResolvedCredential): Promise<VerifiedStatus> {
const client = new AuthClient();
try {
const user = await client.getCurrentUser(credential);
return { credential, user, apiError: null };
} catch (err) {
if (!isAuthError(err)) throw err;
return {
credential,
user: null,
apiError: err instanceof Error ? err.message : String(err),
};
}
}
function printJsonStatus(s: VerifiedStatus): void {
const payload: Record<string, unknown> = {
configured: true,
source: s.credential.source,
type: s.credential.type,
user: s.user,
api_error: s.apiError,
};
if (s.credential.type === "oauth") {
payload["expires_at"] = s.credential.expires_at?.toISOString() ?? null;
payload["refreshable"] = s.credential.refreshable;
payload["scope"] = s.credential.scope ?? null;
}
console.log(JSON.stringify(payload, null, 2));
}
function printHumanStatus(s: VerifiedStatus): void {
const rows = collectStatusRows(s);
for (const [label, value] of rows) console.log(`${c.bold(label)} ${value}`);
}
// fallow-ignore-next-line complexity
function collectStatusRows(s: VerifiedStatus): [string, string][] {
const rows: [string, string][] = [
["Source:", describeSource(s.credential.source)],
["Type: ", s.credential.type === "oauth" ? "oauth" : "api_key"],
];
if (s.credential.type === "oauth") rows.push(...oauthRows(s.credential));
if (s.apiError) {
rows.push([c.error("API check failed:"), s.apiError]);
return rows;
}
if (s.user) rows.push(...identityRows(s.user));
return rows;
}
// fallow-ignore-next-line complexity
function oauthRows(credential: Extract<ResolvedCredential, { type: "oauth" }>): [string, string][] {
const rows: [string, string][] = [];
if (credential.expires_at) {
const fresh = credential.expires_at.getTime() > Date.now();
const tag = fresh ? c.success("(valid)") : c.warn("(expired)");
const refresh = credential.refreshable ? c.dim(" · refreshable") : "";
rows.push(["Expires:", `${credential.expires_at.toISOString()} ${tag}${refresh}`]);
}
if (credential.scope) rows.push(["Scope: ", credential.scope]);
return rows;
}
function identityRows(user: UserInfo): [string, string][] {
const identity = user.email ?? user.username ?? "(unknown user)";
return [["Account:", identity], ...billingRows(user)];
}
const SOURCE_LABELS: Record<ResolvedCredential["source"], string> = {
env: "env (HEYGEN_API_KEY)",
env_alias: "env (HYPERFRAMES_API_KEY)",
file_legacy: "file (~/.heygen/credentials — legacy plaintext)",
file_json: "file (~/.heygen/credentials)",
};
function describeSource(source: ResolvedCredential["source"]): string {
return SOURCE_LABELS[source];
}
function billingRows(user: UserInfo): [string, string][] {
const rows: [string, string][] = [];
if (user.billing_type) rows.push(["Billing:", user.billing_type]);
pushWalletRow(rows, user);
pushSubscriptionRows(rows, user);
pushUsageRow(rows, user);
return rows;
}
// fallow-ignore-next-line complexity
function pushWalletRow(rows: [string, string][], user: UserInfo): void {
const balance = user.wallet?.remaining_balance;
if (balance === undefined) return;
const currency = user.wallet?.currency ? ` ${user.wallet.currency}` : "";
rows.push(["Wallet: ", `${balance}${currency}`]);
}
// fallow-ignore-next-line complexity
function pushSubscriptionRows(rows: [string, string][], user: UserInfo): void {
if (user.subscription?.plan) rows.push(["Plan: ", user.subscription.plan]);
pushCreditRow(rows, "Premium credits:", user.subscription?.credits?.premium_credits);
pushCreditRow(rows, "Add-on credits: ", user.subscription?.credits?.add_on_credits);
}
// fallow-ignore-next-line complexity
function pushCreditRow(
rows: [string, string][],
label: string,
credit: { remaining?: number; resets_at?: string } | undefined,
): void {
if (!credit || credit.remaining === undefined) return;
const resets = credit.resets_at ? ` (resets ${credit.resets_at.slice(0, 10)})` : "";
rows.push([label, `${credit.remaining}${resets}`]);
}
// fallow-ignore-next-line complexity
function pushUsageRow(rows: [string, string][], user: UserInfo): void {
const current = user.usage_based?.spending_current_usd;
if (current === undefined) return;
const cap = user.usage_based?.spending_cap_usd;
const capPart = cap !== undefined ? ` / $${cap}` : "";
rows.push(["Usage: ", `$${current}${capPart}`]);
}
+4
View File
@@ -67,6 +67,10 @@ const GROUPS: Group[] = [
["remove-background", "Remove background from a video or image to produce transparent media"],
],
},
{
title: "Account",
commands: [["auth", "Sign in to HeyGen and manage credentials"]],
},
{
title: "Settings",
commands: [["telemetry", "Manage anonymous usage telemetry"]],