From d03bdeae9d992c9bb653bb1e67cf5569d16455f3 Mon Sep 17 00:00:00 2001 From: James Russo Date: Tue, 3 Nov 2026 01:52:30 +0000 Subject: [PATCH] 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) * 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) * 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) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- docs/guides/authentication.mdx | 15 +- docs/packages/cli.mdx | 23 +- packages/cli/src/auth/_test-utils.ts | 3 + packages/cli/src/auth/errors.ts | 13 +- .../cli/src/auth/media-environment.test.ts | 54 +++++ packages/cli/src/auth/media-environment.ts | 9 +- packages/cli/src/auth/oauth.ts | 15 +- packages/cli/src/auth/resolver.test.ts | 102 ++++++++- packages/cli/src/auth/resolver.ts | 142 +++++++++--- packages/cli/src/cloud/auth.test.ts | 209 ++++++++++++++++++ packages/cli/src/cloud/auth.ts | 44 +++- packages/cli/src/commands/auth.ts | 4 +- packages/cli/src/commands/auth/login.ts | 2 +- packages/cli/src/commands/auth/status.ts | 4 +- packages/cli/src/commands/publish.ts | 2 +- packages/cli/src/heygen/generate.test.ts | 1 + packages/cli/src/utils/publishProject.ts | 4 +- skills-manifest.json | 4 +- skills/hyperframes-cli/references/cloud.md | 2 +- .../audio/references/requirements.md | 14 +- skills/media-use/audio/references/tts.md | 13 +- skills/media-use/audio/scripts/lib/heygen.mjs | 26 ++- .../audio/scripts/lib/heygen.test.mjs | 32 +++ skills/media-use/references/heygen.md | 2 +- 24 files changed, 634 insertions(+), 105 deletions(-) create mode 100644 packages/cli/src/cloud/auth.test.ts diff --git a/docs/guides/authentication.mdx b/docs/guides/authentication.mdx index ba873f48a8..bc849912f9 100644 --- a/docs/guides/authentication.mdx +++ b/docs/guides/authentication.mdx @@ -37,7 +37,7 @@ Signing in is the same OAuth step as creating an account — new users land on t -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) | diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index eff98a2131..d7e9ece34b 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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_*`. | diff --git a/packages/cli/src/auth/_test-utils.ts b/packages/cli/src/auth/_test-utils.ts index bbaceb14e2..e7f18bf05c 100644 --- a/packages/cli/src/auth/_test-utils.ts +++ b/packages/cli/src/auth/_test-utils.ts @@ -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 => { + _resetLoginRefreshFailureForTests(); for (const k of ENV_KEYS) { const v = saved[k]; if (v === undefined) delete process.env[k]; diff --git a/packages/cli/src/auth/errors.ts b/packages/cli/src/auth/errors.ts index 365afbc952..de229d270e 100644 --- a/packages/cli/src/auth/errors.ts +++ b/packages/cli/src/auth/errors.ts @@ -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 = () => diff --git a/packages/cli/src/auth/media-environment.test.ts b/packages/cli/src/auth/media-environment.test.ts index 581809c9c6..9c924f976b 100644 --- a/packages/cli/src/auth/media-environment.test.ts +++ b/packages/cli/src/auth/media-environment.test.ts @@ -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"; diff --git a/packages/cli/src/auth/media-environment.ts b/packages/cli/src/auth/media-environment.ts index f4cb56e26d..4a6044851e 100644 --- a/packages/cli/src/auth/media-environment.ts +++ b/packages/cli/src/auth/media-environment.ts @@ -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(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-")); diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index 38666899df..77d54c52d4 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -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>(); + +/** 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 { + 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 { 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); diff --git a/packages/cli/src/auth/resolver.test.ts b/packages/cli/src/auth/resolver.test.ts index 9450d74475..8d18123246 100644 --- a/packages/cli/src/auth/resolver.test.ts +++ b/packages/cli/src/auth/resolver.test.ts @@ -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 { @@ -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(); diff --git a/packages/cli/src/auth/resolver.ts b/packages/cli/src/auth/resolver.ts index f09559b28a..c8c4007d37 100644 --- a/packages/cli/src/auth/resolver.ts +++ b/packages/cli/src/auth/resolver.ts @@ -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 = { 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 { 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 { + 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>["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[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 { + 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)) { diff --git a/packages/cli/src/cloud/auth.test.ts b/packages/cli/src/cloud/auth.test.ts new file mode 100644 index 0000000000..8ffdca35da --- /dev/null +++ b/packages/cli/src/cloud/auth.test.ts @@ -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>; + 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"); + }); +}); diff --git a/packages/cli/src/cloud/auth.ts b/packages/cli/src/cloud/auth.ts index 26238d37af..6b38e962c4 100644 --- a/packages/cli/src/cloud/auth.ts +++ b/packages/cli/src/cloud/auth.ts @@ -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 { +export async function refreshIfNeeded( + credential: ResolvedCredential, + opts: RefreshIfNeededOptions = {}, +): Promise { 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 { 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); + } } diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 5ea9a583e0..d47cbe1c17 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -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). diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 4ef14592bd..8c8bff365a 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -192,7 +192,7 @@ async function runDeviceLogin(): Promise { 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, diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index 0abfdc5b92..aec9eaec81 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -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); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 4a1fa289b2..f81c4d937e 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -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'.`, )}`, ); diff --git a/packages/cli/src/heygen/generate.test.ts b/packages/cli/src/heygen/generate.test.ts index f397d18a83..b9355b6733 100644 --- a/packages/cli/src/heygen/generate.test.ts +++ b/packages/cli/src/heygen/generate.test.ts @@ -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) => ({ diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index e6b8b9cd48..9e0b2576ab 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -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); diff --git a/skills-manifest.json b/skills-manifest.json index 3c7e1f1777..071ed6abbc 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -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": { diff --git a/skills/hyperframes-cli/references/cloud.md b/skills/hyperframes-cli/references/cloud.md index f2e017a180..ce9add76b3 100644 --- a/skills/hyperframes-cli/references/cloud.md +++ b/skills/hyperframes-cli/references/cloud.md @@ -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 diff --git a/skills/media-use/audio/references/requirements.md b/skills/media-use/audio/references/requirements.md index 3244d005a6..0f77c9004a 100644 --- a/skills/media-use/audio/references/requirements.md +++ b/skills/media-use/audio/references/requirements.md @@ -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. diff --git a/skills/media-use/audio/references/tts.md b/skills/media-use/audio/references/tts.md index 9a386f223e..99e28b691f 100644 --- a/skills/media-use/audio/references/tts.md +++ b/skills/media-use/audio/references/tts.md @@ -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`: diff --git a/skills/media-use/audio/scripts/lib/heygen.mjs b/skills/media-use/audio/scripts/lib/heygen.mjs index b63b637dae..d7d169526e 100644 --- a/skills/media-use/audio/scripts/lib/heygen.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.mjs @@ -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; diff --git a/skills/media-use/audio/scripts/lib/heygen.test.mjs b/skills/media-use/audio/scripts/lib/heygen.test.mjs index 2da87a0c8f..ee0356b71d 100644 --- a/skills/media-use/audio/scripts/lib/heygen.test.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.test.mjs @@ -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"; diff --git a/skills/media-use/references/heygen.md b/skills/media-use/references/heygen.md index 2b11f85da1..23a5d22589 100644 --- a/skills/media-use/references/heygen.md +++ b/skills/media-use/references/heygen.md @@ -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 | | -------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------- |