fix(cli): prefer OAuth session over HEYGEN_API_KEY (#7051)

* fix(cli): prefer OAuth session over HEYGEN_API_KEY

A signed-in `hyperframes auth login` session now wins over an API key in
the environment, so a paid plan on the login is used even when the shell
also exports HEYGEN_API_KEY.

New order: HEYGEN_ACCESS_TOKEN (host app), the stored login (refreshed
when expired), HEYGEN_API_KEY, HYPERFRAMES_API_KEY, the saved API key.
The env key still comes first when HEYGEN_API_URL or HEYGEN_API_BASE
names a host app's gateway, and takes over for the rest of the process
when the login cannot be refreshed. The media-use audio helper follows
the same order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): fall back to the env key only when HeyGen refuses the refresh

A 503, a network error or an overlapping refresh no longer switches the
process to HEYGEN_API_KEY. The fallback now needs a refused grant or an
expired login with no refresh token. A login another process stored since
is re-read first, and refreshes that overlap in one process share one
request. The media-use helper no longer treats HeyGen's own API base as a
gateway.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): keep the login when the refresh reply can't be read

Only a 400/401 from the token endpoint marks the grant as refused and
switches to the env key. A 200 whose body can't be parsed now throws and
leaves the login in place, so the next call refreshes again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-11-03 01:52:30 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 92943d99f0
commit d03bdeae9d
24 changed files with 634 additions and 105 deletions
+8 -7
View File
@@ -37,7 +37,7 @@ Signing in is the same OAuth step as creating an account — new users land on t
</Step>
</Steps>
HyperFrames uses its own public OAuth client and stores credentials in `~/.hyperframes/credentials` (mode `0600`). Sign in again after upgrading from the shared HeyGen login. The separate [`heygen` CLI](https://github.com/heygen-com/heygen-cli) keeps its own login in `~/.heygen`; HyperFrames does not move or delete it. An expired HyperFrames OAuth session requires refresh or sign-in and never selects a saved API key instead. An explicit API-key login or environment key selects API-key billing.
HyperFrames uses its own public OAuth client and stores credentials in `~/.hyperframes/credentials` (mode `0600`). Sign in again after upgrading from the shared HeyGen login. The separate [`heygen` CLI](https://github.com/heygen-com/heygen-cli) keeps its own login in `~/.heygen`; HyperFrames does not move or delete it. A signed-in HyperFrames session wins over `HEYGEN_API_KEY` in the environment. The environment key is used only when no session is signed in, when HeyGen refuses to refresh the session, or when a host app routes calls through its own gateway. An expired session never selects a saved API key instead. An API key, from the environment or `auth login --api-key`, selects API-key billing.
`auth status` verifies identity. For a dedicated HyperFrames OAuth login it reports the billing product, but the HyperFrames balance is unavailable here: `/v3/users/me` returns generic HeyGen account billing fields. Those raw fields remain in JSON and are not presented as the HyperFrames balance. Host-injected or custom-client OAuth is reported as an unverified billing product.
@@ -54,13 +54,14 @@ sound retrieval. It resolves first-match-wins:
1. `HEYGEN_API_BASE` with `HEYGEN_API_KEY` — a host app's own gateway, set by the app that started the workflow
2. `HEYGEN_ACCESS_TOKEN` — an OAuth token a host app injects
3. `HEYGEN_API_KEY` — environment variable
4. `HYPERFRAMES_API_KEY` — alias, for parity with other tools
5. `~/.hyperframes/credentials` — written by `hyperframes auth login`
3. The session `hyperframes auth login` saved in `~/.hyperframes/credentials`, while it is unexpired
4. `HEYGEN_API_KEY` — environment variable
5. `HYPERFRAMES_API_KEY` — alias, for parity with other tools
6. An API key saved in `~/.hyperframes/credentials` by `hyperframes auth login --api-key`
A nearby project `.env` can supply the keys, but never `HEYGEN_API_BASE`. Point at a different config directory
with `HYPERFRAMES_CONFIG_DIR`. The `hyperframes` CLI's own commands check the API keys before `HEYGEN_ACCESS_TOKEN`; see
the [CLI reference](/packages/cli). When `media-use` invokes the separate `heygen` executable, it supplies an isolated temporary credential containing the selected access token. That helper cannot refresh a HyperFrames grant or pick up another HeyGen login.
with `HYPERFRAMES_CONFIG_DIR`. The scripts can't refresh a session; the `hyperframes` CLI's own commands use the same
order and refresh an expired session first. See the [CLI reference](/packages/cli). When `media-use` invokes the separate `heygen` executable, it supplies an isolated temporary credential containing the selected access token. That helper cannot refresh a HyperFrames grant or pick up another HeyGen login.
## Providers used by agent workflows
@@ -71,7 +72,7 @@ vision key is available.
| Capability | Provider order | Key(s) — first match wins | Local dependency |
|------------|----------------|---------------------------|------------------|
| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` → `~/.hyperframes` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` |
| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | HeyGen credential (above) · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` |
| **Music (BGM)** | HeyGen library → Lyria → MusicGen | HeyGen credential (above) · then `GEMINI_API_KEY` → `GOOGLE_API_KEY` | MusicGen: `pip install transformers torch soundfile numpy` |
| **Sound effects** | HeyGen library → bundled library | HeyGen credential (above) | bundled — no deps |
| **Capture descriptions** | OpenRouter → Gemini | `OPENROUTER_API_KEY` → `GEMINI_API_KEY` | None; optional for [website capture](/guides/product-launch-video) |
+15 -8
View File
@@ -936,8 +936,8 @@ FFmpeg.
Only at `--quality best` is audio uploaded. Stems and the `api` provider then
call HeyGen, which may bill your account, with the same credential as the other
HeyGen commands: `HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`, `HEYGEN_ACCESS_TOKEN`,
or a credential saved by `hyperframes auth login`, on the API at
HeyGen commands (see [`hyperframes auth`](#hyperframes-auth) for the order),
on the API at
`HEYGEN_API_URL` when set. API keys are sent as `x-api-key`; OAuth credentials
are sent as bearer tokens. If the service refuses a request, analysis falls
back with a warning. Without a credential, stems are skipped and `api` is
@@ -2294,10 +2294,17 @@ of the `heygen` CLI. A previous shared login requires a fresh HyperFrames sign-i
First match wins:
1. `HEYGEN_API_KEY`
2. `HYPERFRAMES_API_KEY` (a HyperFrames alias for the same thing)
3. `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved)
4. `~/.hyperframes/credentials`
1. `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved)
2. Your `hyperframes auth login` session in `~/.hyperframes/credentials`. An expired session is refreshed first.
3. `HEYGEN_API_KEY`
4. `HYPERFRAMES_API_KEY` (a HyperFrames alias for the same thing)
5. An API key saved with `hyperframes auth login --api-key`
A signed-in session wins over an API key in the environment. The environment key
is used instead when HeyGen refuses to refresh the session, or when `HEYGEN_API_URL` or
`HEYGEN_API_BASE` points at a host app's own gateway. To use the environment key
while signed in, run `hyperframes auth logout`. `auth status` shows which
credential is in use.
### `auth login`
@@ -2358,9 +2365,9 @@ hyperframes auth logout --yes # no prompt
| Variable | Description |
| --------------------- | ------------------------------------------------ |
| `HEYGEN_API_KEY` | Override the stored credential. |
| `HEYGEN_API_KEY` | API key, used when no session is signed in. Wins over a key saved with `--api-key`. |
| `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. |
| `HEYGEN_ACCESS_TOKEN` | OAuth token from a host app; never refreshed. |
| `HEYGEN_ACCESS_TOKEN` | OAuth token from a host app; used first, never refreshed. |
| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). |
| `HYPERFRAMES_CONFIG_DIR` | Credentials root (default `~/.hyperframes`). |
| `HYPERFRAMES_ENV` | `prod` (default) or `dev`; `dev` uses a separate credential directory. Hosts and the OAuth client id come from `HEYGEN_API_URL` and `HYPERFRAMES_OAUTH_*`. |
+3
View File
@@ -9,6 +9,7 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { _resetLoginRefreshFailureForTests } from "./resolver.js";
const ENV_KEYS = [
"HEYGEN_API_KEY",
@@ -48,8 +49,10 @@ export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFix
delete process.env[k];
}
process.env["HYPERFRAMES_CONFIG_DIR"] = dir;
_resetLoginRefreshFailureForTests();
const restore = async (): Promise<void> => {
_resetLoginRefreshFailureForTests();
for (const k of ENV_KEYS) {
const v = saved[k];
if (v === undefined) delete process.env[k];
+11 -2
View File
@@ -17,12 +17,20 @@ export type AuthErrorCode =
export class AuthError extends Error {
readonly code: AuthErrorCode;
readonly hint?: string;
/** HeyGen refused a token refresh (400/401), so the grant is gone; a reply the CLI couldn't read is not this. */
readonly refused: boolean;
constructor(code: AuthErrorCode, message: string, hint?: string) {
constructor(
code: AuthErrorCode,
message: string,
hint?: string,
opts: { refused?: boolean } = {},
) {
super(message);
this.name = "AuthError";
this.code = code;
this.hint = hint;
this.refused = opts.refused ?? false;
}
}
@@ -57,11 +65,12 @@ export const ErrOAuthNotConfigured = () =>
"Set HYPERFRAMES_OAUTH_CLIENT_ID, or run `hyperframes auth login --api-key`.",
);
export const ErrRefreshFailed = (detail?: string) =>
export const ErrRefreshFailed = (detail?: string, opts: { refused?: boolean } = {}) =>
new AuthError(
"REFRESH_FAILED",
detail ? `Failed to refresh OAuth tokens: ${detail}` : "Failed to refresh OAuth tokens",
"Run `hyperframes auth login` to re-authenticate.",
opts,
);
export const ErrLoginExpired = () =>
@@ -61,6 +61,60 @@ describe("media-use authentication", () => {
expect(await fs.readFile(join(fixture.dir, "credentials"), "utf8")).toContain("rotated");
});
it("passes the login, not HEYGEN_API_KEY, when both are present", async () => {
await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" });
process.env["HEYGEN_API_KEY"] = "env-key";
await withMediaAuthEnvironment(async (env) => {
expect(env.HEYGEN_ACCESS_TOKEN).toBe("hf-access");
expect(env.HEYGEN_API_KEY).toBeUndefined();
});
});
it("falls back to HEYGEN_API_KEY when the login cannot be refreshed", async () => {
await persistFreshOAuth({
access_token: "expired",
refresh_token: "hf-refresh",
expires_at: new Date(Date.now() - 60 * 1000).toISOString(),
});
process.env["HEYGEN_API_KEY"] = "env-key";
const fetchMock = vi.fn(async () => new Response("invalid_grant", { status: 400 }));
vi.stubGlobal("fetch", fetchMock);
await withMediaAuthEnvironment(async (env) => {
expect(fetchMock).toHaveBeenCalledOnce();
expect(env.HEYGEN_API_KEY).toBe("env-key");
expect(env.HEYGEN_ACCESS_TOKEN).toBeUndefined();
});
});
it("still fails when the login cannot be refreshed and no env key is set", async () => {
await persistFreshOAuth({
access_token: "expired",
refresh_token: "hf-refresh",
expires_at: new Date(Date.now() - 60 * 1000).toISOString(),
});
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("invalid_grant", { status: 400 })),
);
await expect(withMediaAuthEnvironment(() => undefined)).rejects.toMatchObject({
code: "REFRESH_FAILED",
});
});
it("gives a host gateway its own key even when a login is stored", async () => {
await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" });
process.env["HEYGEN_API_BASE"] = "https://gateway.example";
process.env["HEYGEN_API_KEY"] = "gateway-key";
try {
await withMediaAuthEnvironment(async (env) => {
expect(env.HEYGEN_API_KEY).toBe("gateway-key");
expect(env.HEYGEN_ACCESS_TOKEN).toBeUndefined();
});
} finally {
delete process.env["HEYGEN_API_BASE"];
}
});
it("refuses to send OAuth to a non-HeyGen media gateway", async () => {
await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" });
process.env["HEYGEN_API_BASE"] = "https://gateway.example";
+6 -3
View File
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { apiBaseUrl } from "./client.js";
import { refreshTokens } from "./oauth.js";
import { AuthError, isAuthError } from "./errors.js";
import { resolveCredential, tryResolveCredential } from "./resolver.js";
import { afterRefreshFailure, resolveCredential, tryResolveCredential } from "./resolver.js";
import { writeStore } from "./store.js";
// Avatar helpers can poll for five minutes after submitting paid work.
@@ -28,8 +28,11 @@ export async function withMediaAuthEnvironment<T>(run: (env: NodeJS.ProcessEnv)
credential.refresh_token &&
(credential.refreshable || expiresDuringHelper)
) {
await refreshTokens(credential.refresh_token);
credential = await resolveCredential();
const refreshToken = credential.refresh_token;
credential = await refreshTokens(refreshToken).then(
() => resolveCredential(),
(error: unknown) => afterRefreshFailure(error, refreshToken),
);
}
const dir = await fs.mkdtemp(join(tmpdir(), "hyperframes-media-auth-"));
+13 -2
View File
@@ -355,10 +355,21 @@ function retryAfterSeconds(response: Response, nowMs: number): number | undefine
return Math.min(Math.max(Math.ceil((retryAt - nowMs) / 1000), 0), MAX_DEVICE_POLL_SECONDS);
}
export async function refreshTokens(
const refreshing = new Map<string, Promise<OAuthTokens>>();
/** Refresh the stored login. Calls that overlap in this process share one request, since the server rotates the token. */
export function refreshTokens(
refresh_token: string,
opts: RefreshOptions = {},
): Promise<OAuthTokens> {
const pending = refreshing.get(refresh_token);
if (pending) return pending;
const request = refreshOnce(refresh_token, opts).finally(() => refreshing.delete(refresh_token));
refreshing.set(refresh_token, request);
return request;
}
async function refreshOnce(refresh_token: string, opts: RefreshOptions): Promise<OAuthTokens> {
const { credentials } = await readStore();
if (credentials.oauth?.refresh_token !== refresh_token) throw ErrLoginChanged();
assertOAuthIdentity(credentials.oauth);
@@ -380,7 +391,7 @@ export async function refreshTokens(
});
if (res.status === 400 || res.status === 401) {
throw ErrRefreshFailed(await safeText(res));
throw ErrRefreshFailed(await safeText(res), { refused: true });
}
if (!res.ok) {
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
+98 -4
View File
@@ -3,8 +3,8 @@ import { promises as fs } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
import { isAuthError } from "./errors.js";
import { resolveCredential, tryResolveCredential } from "./resolver.js";
import { ErrApi, ErrLoginChanged, ErrRefreshFailed, isAuthError } from "./errors.js";
import { afterRefreshFailure, resolveCredential, tryResolveCredential } from "./resolver.js";
import { writeStore as writeUnboundStore, type Credentials } from "./store.js";
async function writeStore(credentials: Credentials): Promise<void> {
@@ -27,8 +27,103 @@ describe("auth/resolver", () => {
await fixture.restore();
});
it("prefers HEYGEN_API_KEY over everything else", async () => {
const inOneHour = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
const anHourAgo = () => new Date(Date.now() - 60 * 60 * 1000).toISOString();
it("prefers a signed-in login over HEYGEN_API_KEY", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
await writeStore({
api_key: "file-key",
oauth: { access_token: "fresh-at", refresh_token: "rt", expires_at: inOneHour() },
});
const r = await resolveCredential();
expect(r).toMatchObject({ type: "oauth", access_token: "fresh-at", source: "file_json" });
});
it("prefers an expired login that can refresh over HEYGEN_API_KEY", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
await writeStore({
oauth: { access_token: "stale-at", refresh_token: "rt", expires_at: anHourAgo() },
});
const r = await resolveCredential();
expect(r).toMatchObject({ type: "oauth", access_token: "stale-at", refreshable: true });
});
it("uses HEYGEN_API_KEY when the login expired and cannot refresh", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
await writeStore({ oauth: { access_token: "stale-at", expires_at: anHourAgo() } });
expect(await resolveCredential()).toEqual({ type: "api_key", key: "env-key", source: "env" });
});
it("uses HEYGEN_API_KEY over a login from another OAuth client", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
await writeStore({ oauth: { access_token: "foreign", client_id: "shared-heygen-cli" } });
expect(await resolveCredential()).toEqual({ type: "api_key", key: "env-key", source: "env" });
});
it("uses HEYGEN_API_KEY when the credentials file is broken", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
await fs.writeFile(join(dir, "credentials"), "{not valid", { mode: 0o600 });
expect(await resolveCredential()).toEqual({ type: "api_key", key: "env-key", source: "env" });
});
it("keeps the env key first when HEYGEN_API_URL names a host app's gateway", async () => {
process.env["HEYGEN_API_KEY"] = "gateway-token";
process.env["HEYGEN_API_URL"] = "http://127.0.0.1:4100";
await writeStore({ oauth: { access_token: "fresh-at", expires_at: inOneHour() } });
expect(await resolveCredential()).toEqual({
type: "api_key",
key: "gateway-token",
source: "env",
});
process.env["HEYGEN_API_URL"] = "https://api.dev.heygen.com";
expect(await resolveCredential()).toMatchObject({ type: "oauth", access_token: "fresh-at" });
process.env["HEYGEN_API_BASE"] = "https://gateway.example";
expect(await resolveCredential()).toMatchObject({ type: "api_key", key: "gateway-token" });
});
it("uses the env key for the rest of the process once HeyGen refuses to refresh the login", async () => {
await writeStore({
oauth: { access_token: "at", refresh_token: "rt", expires_at: anHourAgo() },
});
const refused = ErrRefreshFailed("invalid_grant", { refused: true });
await expect(afterRefreshFailure(refused, "rt")).rejects.toMatchObject({
code: "REFRESH_FAILED",
});
expect(await resolveCredential()).toMatchObject({ type: "oauth", refreshable: true });
process.env["HEYGEN_API_KEY"] = "env-key";
// A server error or a login another process is changing says nothing about the grant.
await expect(afterRefreshFailure(ErrApi(503, "down"), "rt")).rejects.toMatchObject({
code: "API_ERROR",
});
await expect(afterRefreshFailure(ErrLoginChanged(), "rt")).rejects.toMatchObject({
code: "LOGIN_CHANGED",
});
// Nor does a reply the CLI couldn't read.
await expect(
afterRefreshFailure(ErrRefreshFailed("no access_token"), "rt"),
).rejects.toMatchObject({ code: "REFRESH_FAILED" });
expect(await resolveCredential()).toMatchObject({ type: "oauth", refreshable: true });
const fallback = { type: "api_key", key: "env-key", source: "env" };
expect(await afterRefreshFailure(refused, "rt")).toEqual(fallback);
expect(await resolveCredential()).toEqual(fallback);
});
it("prefers a host app's HEYGEN_ACCESS_TOKEN over the login and env keys", async () => {
process.env["HEYGEN_ACCESS_TOKEN"] = "host-token";
process.env["HEYGEN_API_KEY"] = "env-key";
await writeStore({ oauth: { access_token: "fresh-at", expires_at: inOneHour() } });
expect(await resolveCredential()).toMatchObject({
type: "oauth",
access_token: "host-token",
source: "env_oauth",
});
});
it("prefers HEYGEN_API_KEY over HYPERFRAMES_API_KEY and a saved API key", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
@@ -37,7 +132,6 @@ describe("auth/resolver", () => {
});
it("falls through to HYPERFRAMES_API_KEY", async () => {
process.env["HEYGEN_ACCESS_TOKEN"] = "host-token";
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
+107 -35
View File
@@ -1,24 +1,19 @@
/**
* Chain resolver for HyperFrames credentials.
* Chain resolver for HyperFrames credentials. First usable source wins:
* 1. `HEYGEN_ACCESS_TOKEN` env (an OAuth token a host app injects)
* 2. The stored login: fresh, or expired with a refresh token
* 3. `HEYGEN_API_KEY` env, then `HYPERFRAMES_API_KEY`
* 4. The API key saved in `~/.hyperframes/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_ACCESS_TOKEN` env (host-managed OAuth)
* 4. `~/.hyperframes/credentials` (JSON) — bound OAuth or an explicit API key
*
* Absent sources fall through. Broken files surface `ErrInvalidStore` immediately;
* silently falling back would mask user configuration errors.
*
* 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
* session requires sign-in; a saved API key never replaces expired OAuth.
* The env key goes first for a host app's gateway, and once HeyGen refused to refresh the login.
* A broken store gives way to an env key, else surfaces `ErrInvalidStore`. A saved API key
* never replaces an expired login (60s skew).
*/
import { isHeaderSafe, readStore } from "./store.js";
import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js";
import { assertOAuthIdentity } from "./config.js";
import { apiBaseUrl } from "./client.js";
type EnvSource = "env" | "env_alias" | "env_oauth";
type CredentialSource = EnvSource | "file_json" | "file_legacy";
@@ -32,7 +27,7 @@ export const ENV_CREDENTIAL_VAR: Record<EnvSource, string> = {
export const envCredentialVar = (source: CredentialSource): string | undefined =>
source in ENV_CREDENTIAL_VAR ? ENV_CREDENTIAL_VAR[source as EnvSource] : undefined;
interface ApiKeyCredential {
export interface ApiKeyCredential {
type: "api_key";
key: string;
source: CredentialSource;
@@ -60,36 +55,113 @@ export interface ResolveOptions {
export async function resolveCredential(opts: ResolveOptions = {}): Promise<ResolvedCredential> {
const now = (opts.now ?? (() => new Date()))();
const heygenEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env);
if (heygenEnv) {
return { type: "api_key", key: heygenEnv, source: "env" };
}
const hfEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env_alias);
if (hfEnv) {
return { type: "api_key", key: hfEnv, source: "env_alias" };
}
const envKey = envApiKey();
// A gateway takes the key its host app set and must never receive an OAuth token.
if (envKey && !heygenApiHost()) return envKey;
const accessToken = headerSafeEnv(ENV_CREDENTIAL_VAR.env_oauth);
if (accessToken) {
return { type: "oauth", access_token: accessToken, source: "env_oauth", refreshable: false };
}
if (envKey && loginRefreshFailed) return envKey;
return await fromStore(now, envKey);
}
const { credentials, source } = await readStore();
if (source === "absent") throw ErrNotConfigured();
const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";
if (credentials.oauth) assertOAuthIdentity(credentials.oauth);
const oauth = credentials.oauth ? pickOAuth(credentials.oauth, now, fileSource) : null;
if (oauth) return oauth;
if (credentials.oauth) throw ErrLoginExpired();
if (credentials.api_key) {
return { type: "api_key", key: credentials.api_key, source: fileSource };
/** The stored login, else the env key, else the stored API key. */
async function fromStore(
now: Date,
envKey: ApiKeyCredential | undefined,
): Promise<ResolvedCredential> {
const store = await readStore().catch((err: unknown) => {
if (envKey) return null;
throw err;
});
if (!store || store.source === "absent") {
if (envKey) return envKey;
throw ErrNotConfigured();
}
const { credentials, source } = store;
const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";
const login =
credentials.oauth && boundToThisClient(credentials.oauth)
? pickOAuth(credentials.oauth, now, fileSource)
: null;
return login ?? envKey ?? storedApiKey(credentials, fileSource);
}
function storedApiKey(
credentials: Awaited<ReturnType<typeof readStore>>["credentials"],
source: CredentialSource,
): ApiKeyCredential {
if (credentials.oauth) {
assertOAuthIdentity(credentials.oauth);
throw ErrLoginExpired();
}
if (credentials.api_key) return { type: "api_key", key: credentials.api_key, source };
throw ErrNotConfigured();
}
function envApiKey(): ApiKeyCredential | undefined {
const heygenEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env);
if (heygenEnv) return { type: "api_key", key: heygenEnv, source: "env" };
const hfEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env_alias);
if (hfEnv) return { type: "api_key", key: hfEnv, source: "env_alias" };
return undefined;
}
/** False when `HEYGEN_API_URL` or `HEYGEN_API_BASE` names a host app's gateway, not HeyGen. */
function heygenApiHost(): boolean {
const mediaBase = process.env["HEYGEN_API_BASE"]?.trim();
return [apiBaseUrl(), ...(mediaBase ? [mediaBase] : [])].every((base) => {
try {
const host = new URL(base).hostname;
return host === "heygen.com" || host.endsWith(".heygen.com");
} catch {
return false;
}
});
}
function boundToThisClient(tokens: Parameters<typeof assertOAuthIdentity>[0]): boolean {
try {
assertOAuthIdentity(tokens);
return true;
} catch {
return false;
}
}
let loginRefreshFailed = false;
/**
* After refreshing `refreshToken` failed: a login another process stored since, else the env key when HeyGen refused
* the grant (it then replaces the login for this process), else the error. A network or server error, or a reply the
* CLI couldn't read, throws: the login may still work, and the next call refreshes again.
*/
export async function afterRefreshFailure(
err: unknown,
refreshToken: string | undefined,
): Promise<ResolvedCredential> {
if (!isAuthError(err)) throw err;
if (err.code === "LOGIN_CHANGED" || err.code === "REFRESH_FAILED") {
const stored = await readStore().catch(() => null);
const current = stored?.credentials.oauth?.refresh_token;
if (current && current !== refreshToken) {
const next = await resolveCredential();
if (next.type !== "oauth" || !next.refreshable) return next;
}
}
if (!err.refused && err.code !== "LOGIN_EXPIRED") throw err;
const key = envApiKey();
if (!key) throw err;
loginRefreshFailed = true;
return key;
}
export function _resetLoginRefreshFailureForTests(): void {
loginRefreshFailed = false;
}
function headerSafeEnv(name: string): string | undefined {
const value = process.env[name];
if (value && !isHeaderSafe(value)) {
+209
View File
@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setupTempAuthEnv } from "../auth/_test-utils.js";
import { persistFreshOAuth } from "../auth/oauth.js";
import { resolveCredential } from "../auth/resolver.js";
import { forceRefreshCredentials, refreshIfNeeded, resolveCloudAuthHeaders } from "./auth.js";
const anHourAgo = () => new Date(Date.now() - 60 * 60 * 1000).toISOString();
function tokenResponse(): Response {
return new Response(
JSON.stringify({ access_token: "refreshed", refresh_token: "rotated", expires_in: 3600 }),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
describe("cloud auth: login vs HEYGEN_API_KEY", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
beforeEach(async () => {
fixture = await setupTempAuthEnv("hf-cloud-auth-");
});
afterEach(async () => {
vi.unstubAllGlobals();
await fixture.restore();
});
it("signs with the login when HEYGEN_API_KEY is also set", async () => {
await persistFreshOAuth({ access_token: "login-at", refresh_token: "rt" });
process.env["HEYGEN_API_KEY"] = "env-key";
const headers = await resolveCloudAuthHeaders();
expect(headers["authorization"]).toBe("Bearer login-at");
expect(headers["x-api-key"]).toBeUndefined();
});
it("signs with HEYGEN_API_KEY when no login is stored", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
const headers = await resolveCloudAuthHeaders();
expect(headers["x-api-key"]).toBe("env-key");
expect(headers["authorization"]).toBeUndefined();
});
it("refreshes an expired login instead of using HEYGEN_API_KEY", async () => {
await persistFreshOAuth({
access_token: "stale",
refresh_token: "rt",
expires_at: anHourAgo(),
});
process.env["HEYGEN_API_KEY"] = "env-key";
const fetchMock = vi.fn(async () => tokenResponse());
vi.stubGlobal("fetch", fetchMock);
const headers = await resolveCloudAuthHeaders();
expect(fetchMock).toHaveBeenCalledOnce();
expect(headers["authorization"]).toBe("Bearer refreshed");
});
it("falls back to HEYGEN_API_KEY when the refresh fails, and does not retry it", async () => {
await persistFreshOAuth({
access_token: "stale",
refresh_token: "rt",
expires_at: anHourAgo(),
});
process.env["HEYGEN_API_KEY"] = "env-key";
const fetchMock = vi.fn(async () => new Response("invalid_grant", { status: 400 }));
vi.stubGlobal("fetch", fetchMock);
expect((await resolveCloudAuthHeaders())["x-api-key"]).toBe("env-key");
expect((await resolveCloudAuthHeaders())["x-api-key"]).toBe("env-key");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("keeps the login through a server error, and refreshes again on the next call", async () => {
await persistFreshOAuth({
access_token: "stale",
refresh_token: "rt",
expires_at: anHourAgo(),
});
process.env["HEYGEN_API_KEY"] = "env-key";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
.mockResolvedValueOnce(tokenResponse());
vi.stubGlobal("fetch", fetchMock);
await expect(resolveCloudAuthHeaders()).rejects.toMatchObject({ code: "API_ERROR" });
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer refreshed");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("keeps the login through a reply it cannot read", async () => {
await persistFreshOAuth({
access_token: "stale",
refresh_token: "rt",
expires_at: anHourAgo(),
});
process.env["HEYGEN_API_KEY"] = "env-key";
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ token_type: "Bearer" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(tokenResponse()),
);
await expect(resolveCloudAuthHeaders()).rejects.toMatchObject({ code: "REFRESH_FAILED" });
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer refreshed");
});
it("keeps the login through a network error", async () => {
await persistFreshOAuth({
access_token: "stale",
refresh_token: "rt",
expires_at: anHourAgo(),
});
process.env["HEYGEN_API_KEY"] = "env-key";
vi.stubGlobal(
"fetch",
vi
.fn()
.mockRejectedValueOnce(new TypeError("fetch failed"))
.mockResolvedValueOnce(tokenResponse()),
);
await expect(resolveCloudAuthHeaders()).rejects.toThrow("fetch failed");
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer refreshed");
});
it("sends one refresh for requests that overlap, and signs both with the login", async () => {
await persistFreshOAuth({
access_token: "stale",
refresh_token: "rt",
expires_at: anHourAgo(),
});
process.env["HEYGEN_API_KEY"] = "env-key";
// The server rotates the token, so a second refresh with "rt" would be refused.
const fetchMock = vi
.fn()
.mockResolvedValueOnce(tokenResponse())
.mockResolvedValue(new Response("invalid_grant", { status: 400 }));
vi.stubGlobal("fetch", fetchMock);
const both = await Promise.all([resolveCloudAuthHeaders(), resolveCloudAuthHeaders()]);
expect(both.map((headers) => headers["authorization"])).toEqual([
"Bearer refreshed",
"Bearer refreshed",
]);
expect(fetchMock).toHaveBeenCalledOnce();
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer refreshed");
});
it("uses the login another process stored instead of the env key", async () => {
await persistFreshOAuth({
access_token: "stale",
refresh_token: "rt",
expires_at: anHourAgo(),
});
process.env["HEYGEN_API_KEY"] = "env-key";
const fetchMock = vi.fn(async () => new Response("invalid_grant", { status: 400 }));
vi.stubGlobal("fetch", fetchMock);
const credential = await resolveCredential();
await persistFreshOAuth({ access_token: "other-process", refresh_token: "rt2" });
expect(await refreshIfNeeded(credential)).toMatchObject({
type: "oauth",
access_token: "other-process",
});
expect(fetchMock).not.toHaveBeenCalled();
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer other-process");
});
it("surfaces the refresh error when no env key is set", async () => {
await persistFreshOAuth({
access_token: "stale",
refresh_token: "rt",
expires_at: anHourAgo(),
});
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("invalid_grant", { status: 400 })),
);
await expect(resolveCloudAuthHeaders()).rejects.toMatchObject({ code: "REFRESH_FAILED" });
});
it("does not fall back when the caller opts out", async () => {
await persistFreshOAuth({
access_token: "stale",
refresh_token: "rt",
expires_at: anHourAgo(),
});
process.env["HEYGEN_API_KEY"] = "env-key";
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("invalid_grant", { status: 400 })),
);
const credential = await resolveCredential();
await expect(refreshIfNeeded(credential, { envFallback: false })).rejects.toMatchObject({
code: "REFRESH_FAILED",
});
});
it("switches to HEYGEN_API_KEY after the server refuses a login that cannot refresh", async () => {
await persistFreshOAuth({ access_token: "revoked", refresh_token: "rt" });
process.env["HEYGEN_API_KEY"] = "env-key";
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("invalid_grant", { status: 400 })),
);
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer revoked");
await forceRefreshCredentials();
expect((await resolveCloudAuthHeaders())["x-api-key"]).toBe("env-key");
});
});
+33 -11
View File
@@ -14,8 +14,13 @@
*/
import { apiBaseUrl, buildAuthHeaders } from "../auth/client.js";
import { ErrLoginExpired } from "../auth/errors.js";
import { refreshTokens } from "../auth/oauth.js";
import { resolveCredential, type ResolvedCredential } from "../auth/resolver.js";
import {
afterRefreshFailure,
resolveCredential,
type ResolvedCredential,
} from "../auth/resolver.js";
/**
* Build the cloud client's `getAuthHeaders` callback. Each invocation
@@ -38,11 +43,25 @@ export function resolveCloudBaseUrl(): string {
return apiBaseUrl();
}
export interface RefreshIfNeededOptions {
/** When HeyGen refuses the refresh, use an API key from the environment instead of throwing. Default true. */
envFallback?: boolean;
}
// fallow-ignore-next-line complexity
export async function refreshIfNeeded(credential: ResolvedCredential): Promise<ResolvedCredential> {
export async function refreshIfNeeded(
credential: ResolvedCredential,
opts: RefreshIfNeededOptions = {},
): Promise<ResolvedCredential> {
if (credential.type !== "oauth") return credential;
if (!credential.refreshable || !credential.refresh_token) return credential;
const fresh = await refreshTokens(credential.refresh_token);
let fresh;
try {
fresh = await refreshTokens(credential.refresh_token);
} catch (err) {
if (opts.envFallback === false) throw err;
return await afterRefreshFailure(err, credential.refresh_token);
}
return {
...credential,
access_token: fresh.access_token,
@@ -63,15 +82,18 @@ function parseDateOrUndef(value: string | undefined): Date | undefined {
* expiry. Used by `createCloudClient`'s 401-retry path when the server
* rejects a token that the local resolver thought was still valid (e.g.
* server-side revocation, clock skew, IdP rotation). No-op for API-key
* credentials.
*
* Throws if the credential can't be refreshed (no refresh_token, or the
* IdP refresh call itself fails). Callers should let the throw surface
* — the original 401 is what the user needs to see, not a confusing
* "refresh failed" message.
* credentials and a host app's token. When HeyGen refuses the refresh,
* an env API key takes over; without one, or on a network or server error,
* this throws, and callers should surface the original 401 rather than a
* confusing "refresh failed".
*/
export async function forceRefreshCredentials(): Promise<void> {
const credential = await resolveCredential();
if (credential.type !== "oauth" || !credential.refresh_token) return;
await refreshTokens(credential.refresh_token);
if (credential.type !== "oauth" || credential.source === "env_oauth") return;
try {
if (!credential.refresh_token) throw ErrLoginExpired();
await refreshTokens(credential.refresh_token);
} catch (err) {
await afterRefreshFailure(err, credential.refresh_token);
}
}
+2 -2
View File
@@ -38,9 +38,9 @@ ${c.bold("SUBCOMMANDS:")}
${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("HEYGEN_API_KEY")} API key; used when no login is active (or it can't refresh), before a saved key.
${c.accent("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
${c.accent("HEYGEN_ACCESS_TOKEN")} OAuth token from a host app; used after the API keys, never refreshed or saved.
${c.accent("HEYGEN_ACCESS_TOKEN")} OAuth token from a host app; used first, never refreshed or saved.
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
${c.accent("HYPERFRAMES_ENV")} prod (default) or dev; dev keeps credentials in a separate directory.
${c.accent("HYPERFRAMES_CONFIG_DIR")} Override the credentials directory (default ~/.hyperframes).
+1 -1
View File
@@ -192,7 +192,7 @@ async function runDeviceLogin(): Promise<void> {
console.log(c.success(`✓ Signed in as ${identity}.`));
}
// The tokens this login just issued, never a resolved credential: an env credential would outrank them.
// The tokens this login just issued, never a resolved credential: an env credential can outrank them.
function issuedCredential(tokens: { access_token: string; refresh_token?: string }) {
return {
type: "oauth" as const,
+3 -1
View File
@@ -29,6 +29,7 @@ import {
type StoredUserInfo,
type UserInfo,
} from "../../auth/index.js";
import { refreshIfNeeded } from "../../cloud/auth.js";
import { getSystemMeta } from "../../telemetry/system.js";
import { c } from "../../ui/colors.js";
import { pageUrl, readPlan, type Plan } from "../../heygen/plan.js";
@@ -95,7 +96,8 @@ export default defineCommand({
return;
}
const status = await verify(credential);
const resolved = credential;
const status = await verify(await refreshIfNeeded(resolved).catch(() => resolved));
if (asJson) printJsonStatus(status);
else printHumanStatus(status);
setCommandExitCode(status.apiError ? 1 : 0);
+1 -1
View File
@@ -194,7 +194,7 @@ export default defineCommand({
!credential
? `${flag} requires authentication. Run 'hyperframes auth login' first.`
: envKey
? `${flag} requires a login; ${envKey} cannot own a project. Unset it and run 'hyperframes auth login'.`
? `${flag} requires a login; ${envKey} cannot own a project. Run 'hyperframes auth login'.`
: `${flag} requires a login; an API key cannot own a project. Run 'hyperframes auth login'.`,
)}`,
);
+1
View File
@@ -13,6 +13,7 @@ vi.mock("../cloud/auth.js", () => ({
}));
vi.stubEnv("HEYGEN_API_KEY", "k");
vi.stubEnv("HEYGEN_API_URL", "https://api.test");
const account = vi.hoisted(() => ({ scope: "acct" as string | null }));
vi.mock("./v3.js", async (load) => ({
+3 -1
View File
@@ -190,7 +190,9 @@ export async function resolvePublishCredential(
const expired =
credential.type === "oauth" && isTokenExpired(credential.expires_at, new Date());
if (expired && !credential.refresh_token) throw new Error(LOGIN_EXPIRED);
return await refreshIfNeeded(expired ? { ...credential, refreshable: true } : credential);
return await refreshIfNeeded(expired ? { ...credential, refreshable: true } : credential, {
envFallback: checked === undefined,
});
} catch (error) {
if (isAuthError(error) && (error.code === "REFRESH_FAILED" || error.code === "LOGIN_EXPIRED")) {
throw new Error(LOGIN_EXPIRED);
+2 -2
View File
@@ -10,7 +10,7 @@
"files": 77
},
"hyperframes-cli": {
"hash": "5d58eaa2a0da370d",
"hash": "aeb9494dee6777a7",
"files": 13
},
"hyperframes-registry": {
@@ -18,7 +18,7 @@
"files": 13
},
"media-use": {
"hash": "acd9a1b97b426bd2",
"hash": "39040df75b0436da",
"files": 117
},
"remotion-to-hyperframes": {
+1 -1
View File
@@ -32,7 +32,7 @@ npx hyperframes auth refresh # force-refresh an OAuth token before a
npx hyperframes auth logout # clear the stored credential
```
Credential resolution order (first match wins): `HEYGEN_API_KEY`, then `HYPERFRAMES_API_KEY`, then `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved), then `~/.hyperframes/credentials`. `HYPERFRAMES_ENV=dev` selects the dev client, OAuth/API hosts and `~/.hyperframes/dev/credentials` together. `HYPERFRAMES_CONFIG_DIR` overrides the credentials root. `HEYGEN_API_URL` overrides the API host.
Credential resolution order (first match wins): `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved), then the `hyperframes auth login` session in `~/.hyperframes/credentials` (refreshed when expired), then `HEYGEN_API_KEY`, then `HYPERFRAMES_API_KEY`, then a key saved with `auth login --api-key`. A signed-in session wins over an env key; the env key is used if HeyGen refuses to refresh the session or `HEYGEN_API_URL` points at a host app's gateway. `auth status` shows which one is in use; `auth logout` makes the env key win. `HYPERFRAMES_ENV=dev` selects the dev client, OAuth/API hosts and `~/.hyperframes/dev/credentials` together. `HYPERFRAMES_CONFIG_DIR` overrides the credentials root. `HEYGEN_API_URL` overrides the API host.
## The render pipeline
@@ -4,13 +4,13 @@
Run `npx hyperframes auth status` to see what's configured and which engines a workflow will use (see the skill's **Preflight** section). Keys resolve in this order — **first match wins**:
| Provider | Resolution order (first non-empty wins) | Local deps when used |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
| **HeyGen** (TTS + BGM retrieval) | `$HEYGEN_ACCESS_TOKEN` → `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.hyperframes/credentials` (`$HYPERFRAMES_CONFIG_DIR` overrides the root; dev uses `dev/`) | none (REST) |
| **ElevenLabs** (TTS fallback) | `$ELEVENLABS_API_KEY` | `pip install elevenlabs` |
| **Lyria** (BGM fallback) | `$GEMINI_API_KEY` → `$GOOGLE_API_KEY` | `pip install google-genai` |
| **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` |
| **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` |
| Provider | Resolution order (first non-empty wins) | Local deps when used |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| **HeyGen** (TTS + BGM retrieval) | `$HEYGEN_ACCESS_TOKEN` → the `~/.hyperframes/credentials` login → `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → a saved API key (`$HYPERFRAMES_CONFIG_DIR` overrides the root; dev uses `dev/`) | none (REST) |
| **ElevenLabs** (TTS fallback) | `$ELEVENLABS_API_KEY` | `pip install elevenlabs` |
| **Lyria** (BGM fallback) | `$GEMINI_API_KEY` → `$GOOGLE_API_KEY` | `pip install google-genai` |
| **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` |
| **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` |
`hyperframes auth login` (browser OAuth) is the recommended setup: one sign-in, every project, no per-repo `.env`. An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; only OAuth is tagged with `X-HeyGen-Source: cli`. The server decides HyperFrames eligibility and charges; API keys use API billing. An expired or foreign OAuth session requires refresh/sign-in and never selects a saved API key. With no HeyGen credential, voice/BGM run fully locally (Kokoro / MusicGen) — `hyperframes auth status` and `hyperframes doctor` both report whether those local deps are installed.
+7 -6
View File
@@ -59,15 +59,16 @@ the skill's bundled script, which calls the HeyGen v3 REST API directly and need
no CLI provider plumbing:
The script resolves a HeyGen credential the same way the CLI does — first source
wins: a host-injected OAuth `$HEYGEN_ACCESS_TOKEN` → `$HEYGEN_API_KEY` →
`$HYPERFRAMES_API_KEY` → a project `.env` (auto-loaded,
walks up ≤5 dirs) → `~/.hyperframes/credentials` (isolated from heygen-cli;
wins: a host-injected OAuth `$HEYGEN_ACCESS_TOKEN` → an unexpired login in
`~/.hyperframes/credentials` → `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → a
project `.env` (auto-loaded, walks up ≤5 dirs) → an API key saved in
`~/.hyperframes/credentials` (isolated from heygen-cli;
`$HYPERFRAMES_CONFIG_DIR` overrides the root; `HYPERFRAMES_ENV=dev` uses `dev/`). An OAuth login is sent as
`Authorization: Bearer`; an API key as `X-Api-Key`; only OAuth includes
`X-HeyGen-Source: cli`. The server decides HyperFrames eligibility and charges;
explicit API keys use API billing. An expired or foreign OAuth session stops
rather than selecting a saved API key, with a hint to run
`npx hyperframes auth refresh`.
explicit API keys use API billing. An expired or foreign OAuth session gives way
to an env key; without one it stops rather than selecting a saved API key, with
a hint to run `npx hyperframes auth refresh`.
```bash
# Only needed if you haven't run `npx hyperframes auth login`:
+16 -10
View File
@@ -3,10 +3,12 @@ import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
// pipeline. The credential resolver matches the hyperframes CLI auth: first
// usable source wins — a host gateway ($HEYGEN_API_BASE with its own
// $HEYGEN_API_KEY) → a host-injected OAuth $HEYGEN_ACCESS_TOKEN (Bearer) →
// $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.hyperframes/
// credentials (OAuth never falls back to an API key; $HYPERFRAMES_CONFIG_DIR
// overrides the dir). $HEYGEN_API_BASE moves every request to that host, as it
// does for the heygen CLI; plain HTTP needs $HEYGEN_ALLOW_HTTP=1, as there.
// the live login in ~/.hyperframes/credentials → $HEYGEN_API_KEY /
// $HYPERFRAMES_API_KEY (or a nearby .env) → the API key saved in
// ~/.hyperframes/credentials. An expired login gives way to an env key but
// never to the saved API key ($HYPERFRAMES_CONFIG_DIR overrides the dir).
// $HEYGEN_API_BASE moves every request to that host, as it does for the
// heygen CLI; plain HTTP needs $HEYGEN_ALLOW_HTTP=1, as there.
// Vendored so the skill ships standalone. Pure node.
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -40,12 +42,10 @@ function heygenOwnBase() {
}
}
// A host gateway: the host named its own API base and the key that base accepts. It pays for every call, so it wins
// over any other credential the environment carries.
// A host gateway: the host pointed the API base away from HeyGen and set the key that base accepts. It pays for every
// call, so it wins over any other credential the environment carries.
const hostGatewayKey = () =>
process.env.HEYGEN_API_BASE?.trim() && process.env.HEYGEN_API_KEY
? process.env.HEYGEN_API_KEY
: null;
!heygenOwnBase() && process.env.HEYGEN_API_KEY ? process.env.HEYGEN_API_KEY : null;
export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
// Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
// the backend can isolate media-use consumption from other free TTS / avatar video.
@@ -114,8 +114,14 @@ function resolveCredential() {
const accessToken = process.env.HEYGEN_ACCESS_TOKEN;
if (accessToken) return { headers: { Authorization: `Bearer ${accessToken}` } };
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
if (envKey) return { headers: { "X-Api-Key": envKey } };
const stored = storedCredential();
// A live login wins over an env key; an expired, foreign or unreadable store gives way to one.
if (stored?.headers && "Authorization" in stored.headers) return stored;
return envKey ? { headers: { "X-Api-Key": envKey } } : stored;
}
// The credential saved in ~/.hyperframes/credentials: { headers } | { expired: true } | { unreadable } | null.
function storedCredential() {
const root = process.env.HYPERFRAMES_CONFIG_DIR || join(homedir(), ".hyperframes");
const file = join(root, ...(process.env.HYPERFRAMES_ENV === "dev" ? ["dev"] : []), "credentials");
let raw;
@@ -129,6 +129,38 @@ test("a host-injected HEYGEN_ACCESS_TOKEN wins over an API key and is treated as
});
});
test("a live login wins over HEYGEN_API_KEY; an expired one gives way to it", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "hf-login-vs-env-"));
try {
process.env.HYPERFRAMES_CONFIG_DIR = dir;
process.env.HEYGEN_API_KEY = "hg_env";
const credential = {
api_key: "saved-wallet",
oauth: {
client_id: "Zg3LF4FCLoeJoGs2YqMtZ17g",
token_endpoint: "https://api2.heygen.com/v1/oauth/token",
access_token: "at_login",
expires_at: "2099-01-01T00:00:00Z",
},
};
writeFileSync(join(dir, "credentials"), JSON.stringify(credential));
assert.deepEqual(heygenCredential(), { headers: { Authorization: "Bearer at_login" } });
// HeyGen's own base is no gateway, so naming it changes nothing.
process.env.HEYGEN_API_BASE = "https://api.heygen.com";
assert.deepEqual(heygenCredential(), { headers: { Authorization: "Bearer at_login" } });
delete process.env.HEYGEN_API_BASE;
credential.oauth.expires_at = "2000-01-01T00:00:00Z";
writeFileSync(join(dir, "credentials"), JSON.stringify(credential));
assert.deepEqual(heygenCredential(), { headers: { "X-Api-Key": "hg_env" } });
writeFileSync(join(dir, "credentials"), JSON.stringify({ api_key: "saved-wallet" }));
assert.deepEqual(heygenCredential(), { headers: { "X-Api-Key": "hg_env" } });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("heygenAuthMethod returns api_key for an env API key, without tagging headers", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_API_KEY = "hg_test";
+1 -1
View File
@@ -4,7 +4,7 @@
## Signing in
`npx hyperframes auth status` says what is signed in. The credential is read in this order: `HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`, a host app's `HEYGEN_ACCESS_TOKEN`, then `~/.hyperframes/credentials`, which `npx hyperframes auth login` writes. `heygen auth login` is a separate sign-in that only the `heygen` CLI reads.
`npx hyperframes auth status` says what is signed in. The credential is read in this order: a host app's `HEYGEN_ACCESS_TOKEN`, the session `npx hyperframes auth login` saved in `~/.hyperframes/credentials`, `HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`, then an API key saved with `auth login --api-key`. A signed-in session wins over an env key; the env key is used when HeyGen refuses to refresh the session. `heygen auth login` is a separate sign-in that only the `heygen` CLI reads.
| Credential | Pays with | Covers |
| -------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------- |