diff --git a/docs/guides/authentication.mdx b/docs/guides/authentication.mdx index 93564714cee..1ba18be4629 100644 --- a/docs/guides/authentication.mdx +++ b/docs/guides/authentication.mdx @@ -47,14 +47,19 @@ The credential lives in `~/.heygen/credentials` (mode `0600`) — no per-repo `. ## How the HeyGen credential resolves -Bundled media workflows use the HeyGen credential for hosted TTS and music / +The bundled audio scripts use the HeyGen credential for hosted TTS and music / sound retrieval. It resolves first-match-wins: -1. `HEYGEN_API_KEY` — environment variable -2. `HYPERFRAMES_API_KEY` — alias, for parity with other tools -3. `~/.heygen/credentials` — written by `hyperframes auth login` (or `heygen auth login`) +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. `~/.heygen/credentials` — written by `hyperframes auth login` (or `heygen auth login`) -Point at a different config directory with `HEYGEN_CONFIG_DIR`, or a different backend with `HEYGEN_API_URL`. +A nearby project `.env` can supply the keys, but never `HEYGEN_API_BASE`. Point at a different config directory +with `HEYGEN_CONFIG_DIR`. The `hyperframes` CLI's own commands check the API keys before `HEYGEN_ACCESS_TOKEN`; see +the [CLI reference](/packages/cli). `media-use resolve` searches through the separate `heygen` CLI, which resolves its +own credential. ## Providers used by agent workflows @@ -135,9 +140,11 @@ to a shared space with `--space`. | Variable | Used for | |----------|----------| -| `HEYGEN_API_KEY` | HeyGen credential — voice + music/SFX retrieval. Highest priority. | +| `HEYGEN_API_KEY` | HeyGen credential — voice + music/SFX retrieval. | | `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. | -| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). | +| `HEYGEN_ACCESS_TOKEN` | OAuth token a host app injects; never refreshed or saved. | +| `HEYGEN_API_BASE` | A host app's gateway for media workflows; never read from a project `.env`. | +| `HEYGEN_API_URL` | API base URL for `hyperframes` CLI commands (default `https://api.heygen.com`). | | `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). | | `ELEVENLABS_API_KEY` | ElevenLabs TTS, used when no HeyGen credential is present. | | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Explicit Gemini TTS selection and Lyria music generation; capture descriptions use `GEMINI_API_KEY`. | diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index b3e218930bb..8c4d6bf6c1e 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -1383,7 +1383,8 @@ First match wins: 1. `HEYGEN_API_KEY` 2. `HYPERFRAMES_API_KEY` (a HyperFrames alias for the same thing) -3. `~/.heygen/credentials` +3. `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved) +4. `~/.heygen/credentials` ### `auth login` @@ -1441,6 +1442,7 @@ hyperframes auth logout --yes # no prompt | --------------------- | ------------------------------------------------ | | `HEYGEN_API_KEY` | Override the stored credential. | | `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. | +| `HEYGEN_ACCESS_TOKEN` | OAuth token from a host app; never refreshed. | | `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). | | `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). | diff --git a/packages/cli/src/auth/_test-utils.ts b/packages/cli/src/auth/_test-utils.ts index 7fa9743188d..4afd6cd224a 100644 --- a/packages/cli/src/auth/_test-utils.ts +++ b/packages/cli/src/auth/_test-utils.ts @@ -12,6 +12,7 @@ import { join } from "node:path"; const ENV_KEYS = [ "HEYGEN_API_KEY", + "HEYGEN_ACCESS_TOKEN", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR", "HEYGEN_API_URL", diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 9d9562b1cc9..0de5d3e0b24 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -25,7 +25,7 @@ export { export { configDir, credentialPath } from "./paths.js"; -export { tryResolveCredential } from "./resolver.js"; +export { ENV_CREDENTIAL_VAR, tryResolveCredential } from "./resolver.js"; export type { ResolvedCredential } from "./resolver.js"; export { AuthClient } from "./client.js"; diff --git a/packages/cli/src/auth/resolver.test.ts b/packages/cli/src/auth/resolver.test.ts index b69a40799f8..6119879381e 100644 --- a/packages/cli/src/auth/resolver.test.ts +++ b/packages/cli/src/auth/resolver.test.ts @@ -20,6 +20,7 @@ describe("auth/resolver", () => { }); it("prefers HEYGEN_API_KEY over everything else", async () => { + process.env["HEYGEN_ACCESS_TOKEN"] = "host-token"; process.env["HEYGEN_API_KEY"] = "env-key"; process.env["HYPERFRAMES_API_KEY"] = "alias-key"; await writeStore({ api_key: "file-key" }); @@ -28,12 +29,35 @@ 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(); expect(r).toEqual({ type: "api_key", key: "alias-key", source: "env_alias" }); }); + it("accepts host-managed OAuth without a credential file or a refresh token", async () => { + process.env["HEYGEN_ACCESS_TOKEN"] = "host-token"; + expect(await resolveCredential()).toEqual({ + type: "oauth", + access_token: "host-token", + source: "env_oauth", + refreshable: false, + }); + expect(await fs.readdir(dir)).toEqual([]); + }); + + it("rejects an unsafe host token without exposing it", async () => { + process.env["HEYGEN_ACCESS_TOKEN"] = "secret\r\nInjected: value"; + await expect(resolveCredential()).rejects.toMatchObject({ code: "INVALID_STORE" }); + await expect(resolveCredential()).rejects.not.toThrow("secret"); + }); + + it("treats an empty host token as absent", async () => { + process.env["HEYGEN_ACCESS_TOKEN"] = ""; + expect(await tryResolveCredential()).toBeNull(); + }); + it("returns file api_key when no env is set", async () => { 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 404959621a1..f2278ab7301 100644 --- a/packages/cli/src/auth/resolver.ts +++ b/packages/cli/src/auth/resolver.ts @@ -4,11 +4,11 @@ * Priority — first non-empty wins: * 1. `HEYGEN_API_KEY` env (matches heygen-cli) * 2. `HYPERFRAMES_API_KEY` env (alias for parity with other tools) - * 3. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key + * 3. `HEYGEN_ACCESS_TOKEN` env (host-managed OAuth) + * 4. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key * - * Absent sources fall through. A broken file (parse error, bad shape) - * surfaces immediately as `ErrInvalidStore` — silently falling back - * would mask user config bugs. + * 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 @@ -19,7 +19,17 @@ import { isHeaderSafe, readStore } from "./store.js"; import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js"; -type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy"; +type EnvSource = "env" | "env_alias" | "env_oauth"; +type CredentialSource = EnvSource | "file_json" | "file_legacy"; + +export const ENV_CREDENTIAL_VAR: Record = { + env: "HEYGEN_API_KEY", + env_alias: "HYPERFRAMES_API_KEY", + env_oauth: "HEYGEN_ACCESS_TOKEN", +}; + +export const envCredentialVar = (source: CredentialSource): string | undefined => + source in ENV_CREDENTIAL_VAR ? ENV_CREDENTIAL_VAR[source as EnvSource] : undefined; interface ApiKeyCredential { type: "api_key"; @@ -49,22 +59,21 @@ export interface ResolveOptions { export async function resolveCredential(opts: ResolveOptions = {}): Promise { const now = (opts.now ?? (() => new Date()))(); - const heygenEnv = process.env["HEYGEN_API_KEY"]; - if (heygenEnv && heygenEnv.length > 0) { - if (!isHeaderSafe(heygenEnv)) { - throw ErrInvalidStore("HEYGEN_API_KEY contains control characters"); - } + const heygenEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env); + if (heygenEnv) { return { type: "api_key", key: heygenEnv, source: "env" }; } - const hfEnv = process.env["HYPERFRAMES_API_KEY"]; - if (hfEnv && hfEnv.length > 0) { - if (!isHeaderSafe(hfEnv)) { - throw ErrInvalidStore("HYPERFRAMES_API_KEY contains control characters"); - } + const hfEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env_alias); + if (hfEnv) { return { type: "api_key", key: hfEnv, source: "env_alias" }; } + const accessToken = headerSafeEnv(ENV_CREDENTIAL_VAR.env_oauth); + if (accessToken) { + return { type: "oauth", access_token: accessToken, source: "env_oauth", refreshable: false }; + } + const { credentials, source } = await readStore(); if (source === "absent") throw ErrNotConfigured(); @@ -78,6 +87,14 @@ export async function resolveCredential(opts: ResolveOptions = {}): Promise ({ revoke: vi.fn(async () => {}), })); +const usersByToken = vi.hoisted((): Record> => ({})); + +const browserAuth = vi.hoisted(() => ({ + start: vi.fn(async () => { + const tokens = { access_token: "browser-at", token_type: "Bearer" }; + const { writeStore: write } = await import("../../auth/store.js"); + await write({ oauth: { access_token: tokens.access_token } }); + return { tokens }; + }), +})); + vi.mock("../../auth/index.js", async (orig) => { const actual = await orig(); class MockAuthClient { - async getCurrentUser(): Promise> { + async getCurrentUser(credential: { access_token?: string }): Promise> { if (verifyState.reject) { const { ErrUnauthenticated: rej } = await import("../../auth/errors.js"); throw rej("invalid token"); } - return verifyState.user; + return (credential.access_token && usersByToken[credential.access_token]) || verifyState.user; } } return { ...actual, AuthClient: MockAuthClient, + assertOAuthConfiguredOrExit: () => {}, + startAuthorizationCodeFlow: browserAuth.start, startDeviceAuthorizationFlow: deviceAuth.start, persistVerifiedOAuthSession: deviceAuth.persist, revokeTokens: deviceAuth.revoke, @@ -111,6 +124,7 @@ describe("auth login", () => { verifyState.reject = false; verifyState.user = { email: "alice@example.com" }; deviceChallenge.verificationUriComplete = undefined; + for (const token of Object.keys(usersByToken)) delete usersByToken[token]; for (const fn of Object.values(telemetry)) fn.mockClear(); for (const fn of Object.values(deviceAuth)) fn.mockClear(); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -339,6 +353,18 @@ describe("auth login", () => { expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("device", "rejected"); }); + it("reports the account a browser login just signed in, not a host token in the environment", async () => { + process.env["HEYGEN_ACCESS_TOKEN"] = "env-at"; + usersByToken["env-at"] = { email: "a@example.com" }; + usersByToken["browser-at"] = { email: "b@example.com" }; + await runCommand({}); + + const { credentials } = await readStore(); + expect(credentials.oauth?.access_token).toBe("browser-at"); + expect(credentials.user?.email).toBe("b@example.com"); + expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("oauth", "b@example.com"); + }); + it("refuses device authorization in CI", async () => { process.env["CI"] = "true"; await expect(runCommand({ device: true })).rejects.toThrow(/Invalid command usage/); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 3703c3e45b3..c65f37c85a6 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -41,7 +41,6 @@ import { persistVerifiedOAuthSession, startAuthorizationCodeFlow, startDeviceAuthorizationFlow, - tryResolveCredential, userDisplayName, writeStore, type Credentials, @@ -152,16 +151,9 @@ async function runDeviceLogin(): Promise { failCommand(); } - const credential = { - type: "oauth" as const, - access_token: tokens.access_token, - ...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}), - source: "file_json" as const, - refreshable: false, - }; let user: UserInfo; try { - user = await new AuthClient().getCurrentUser(credential); + user = await new AuthClient().getCurrentUser(issuedCredential(tokens)); } catch (err) { await revokeDeviceTokens(tokens); trackAuthLoginFailed("device", "rejected"); @@ -193,6 +185,17 @@ 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. +function issuedCredential(tokens: { access_token: string; refresh_token?: string }) { + return { + type: "oauth" as const, + access_token: tokens.access_token, + ...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}), + source: "file_json" as const, + refreshable: false, + }; +} + async function revokeDeviceTokens(tokens: { access_token: string; refresh_token?: string; @@ -212,8 +215,9 @@ async function runOAuthLogin(): Promise { const { trackAuthLoginStarted, trackAuthLoginFailed } = await import("../../telemetry/index.js"); trackAuthLoginStarted("oauth"); + let tokens; try { - await startAuthorizationCodeFlow(); + ({ tokens } = await startAuthorizationCodeFlow()); } catch (err) { const message = (err as Error).message ?? ""; // The loopback server rejects with "OAuth callback timed out after …" when @@ -225,19 +229,11 @@ async function runOAuthLogin(): Promise { failCommand(); } - await reportIdentity(); + await reportIdentity(issuedCredential(tokens)); } -// fallow-ignore-next-line complexity -async function reportIdentity(): Promise { - const { trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } = - await import("../../telemetry/index.js"); - const credential = await tryResolveCredential(); - if (!credential) { - trackAuthLoginFailed("oauth", "no_credential"); - console.error(c.warn("Sign-in completed but no credential was persisted.")); - failCommand(); - } +async function reportIdentity(credential: ReturnType): Promise { + const { trackAuthLoginCompleted, identifyUser } = await import("../../telemetry/index.js"); // Wire the refresh hook here too — a freshly-minted token shouldn't // need it, but a fast IdP-side rotation (or a misconfigured short // TTL) shouldn't punish the user with a hard failure when the diff --git a/packages/cli/src/commands/auth/logout.test.ts b/packages/cli/src/commands/auth/logout.test.ts new file mode 100644 index 00000000000..68dcac48f92 --- /dev/null +++ b/packages/cli/src/commands/auth/logout.test.ts @@ -0,0 +1,24 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { setupTempAuthEnv, type EnvFixture } from "../../auth/_test-utils.js"; + +let envFixture: EnvFixture; + +beforeEach(async () => { + envFixture = await setupTempAuthEnv("hf-logout-"); + vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await envFixture.restore(); +}); + +it("warns that a host access token still signs commands after logout", async () => { + process.env["HEYGEN_ACCESS_TOKEN"] = "host-token"; + const cmd = (await import("./logout.js")).default; + await (cmd.run as (ctx: { args: Record }) => Promise)({ + args: { yes: true }, + }); + + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Unset HEYGEN_ACCESS_TOKEN")); +}); diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 459e886dc05..a54cd2d62fe 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -4,12 +4,13 @@ import { failCommand } from "../../utils/commandResult.js"; * `--keep-api-key`, only the OAuth block is cleared (no-op for * API-key-only stores). * - * Env-only credentials (`HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`) can't - * be cleared by this command — we tell the user to unset them. + * Env-only credentials (`HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`, + * `HEYGEN_ACCESS_TOKEN`) can't be cleared by this command — we tell the user to unset them. */ import { defineCommand } from "citty"; import { + ENV_CREDENTIAL_VAR, clearOAuth, configDir, credentialPath, @@ -57,11 +58,10 @@ export default defineCommand({ }); function warnIfEnvCredentialActive(): void { - if (process.env["HEYGEN_API_KEY"] || process.env["HYPERFRAMES_API_KEY"]) { + const active = Object.values(ENV_CREDENTIAL_VAR).filter((name) => process.env[name]); + if (active.length > 0) { console.log( - c.warn( - "An env-var credential is active. Unset HEYGEN_API_KEY / HYPERFRAMES_API_KEY to remove it.", - ), + c.warn(`An env-var credential is active. Unset ${active.join(" / ")} to remove it.`), ); } } diff --git a/packages/cli/src/commands/auth/status-user.test.ts b/packages/cli/src/commands/auth/status-user.test.ts index 1293dbf9b12..5bce36e4a88 100644 --- a/packages/cli/src/commands/auth/status-user.test.ts +++ b/packages/cli/src/commands/auth/status-user.test.ts @@ -64,6 +64,17 @@ describe("auth status — persisted user block surface", () => { return JSON.parse(stdout[stdout.length - 1] ?? "{}"); } + it("reports host-managed OAuth without showing its token or a stored identity", async () => { + process.env["HEYGEN_ACCESS_TOKEN"] = "fixture-host-secret"; + await writeStore({ user: { email: "stored@example.com" } }); + expect(await runStatus(true)).toBe(0); + expect(lastJson()).toMatchObject({ source: "env_oauth", persisted_user: null }); + expect(await runStatus(false)).toBe(0); + expect(stdout.join("\n")).toContain("env (HEYGEN_ACCESS_TOKEN)"); + expect(stdout.join("\n")).not.toContain("fixture-host-secret"); + expect(stdout.join("\n")).not.toContain("stored@example.com"); + }); + it("surfaces the persisted user block (with resolved display_name) for a file credential", async () => { await writeStore({ api_key: "hg_x", diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index 6b059650931..0597df84884 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -17,6 +17,7 @@ import { failCommand, setCommandExitCode } from "../../utils/commandResult.js"; import { defineCommand } from "citty"; import { + ENV_CREDENTIAL_VAR, AuthClient, isAuthError, loadUserInfo, @@ -258,8 +259,9 @@ function identityRows(user: UserInfo): [string, string][] { } const SOURCE_LABELS: Record = { - env: "env (HEYGEN_API_KEY)", - env_alias: "env (HYPERFRAMES_API_KEY)", + env: `env (${ENV_CREDENTIAL_VAR.env})`, + env_alias: `env (${ENV_CREDENTIAL_VAR.env_alias})`, + env_oauth: `env (${ENV_CREDENTIAL_VAR.env_oauth})`, file_legacy: "file (~/.heygen/credentials — legacy plaintext)", file_json: "file (~/.heygen/credentials)", }; diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 70810019ea9..ddbe4545c49 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -12,8 +12,8 @@ import { lintProject, } from "../utils/lintProject.js"; import { formatLintStartupMessage } from "../utils/lintFormat.js"; +import { envCredentialVar } from "../auth/resolver.js"; import { - API_KEY_ENV_VAR, buildPublishFileMap, publishProjectArchive, resolvePublishCredential, @@ -183,10 +183,7 @@ export default defineCommand({ if (updateTarget || spaceOverride) { if (credential?.type !== "oauth") { const flag = updateTarget ? "--update" : "--space"; - const envKey = - credential?.source === "env" || credential?.source === "env_alias" - ? API_KEY_ENV_VAR[credential.source] - : undefined; + const envKey = credential && envCredentialVar(credential.source); console.log(); console.log( ` ${c.error( diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 32f890beeb3..d91fb86ff01 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -828,7 +828,6 @@ export type AuthLoginMethod = "oauth" | "device" | "api_key"; export type AuthLoginFailureReason = | "flow_error" // OAuth authorization/exchange threw a real error | "flow_timeout" // OAuth callback wait elapsed (user closed the tab / walked away) - | "no_credential" // flow reported success but nothing was persisted | "rejected" // backend rejected the supplied API key (401) | "invalid_input" // key was empty, header-unsafe, or too short | "aborted"; // prompt cancelled, or no key arrived on stdin before timeout diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index 7878c2cd389..da3ea5421cb 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -1174,6 +1174,25 @@ describe("publishProjectArchive with a credential the server rejects", () => { rmSync(dir, { recursive: true, force: true }); } }); + it("names a rejected host access token instead of asking for a login it would not use", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(unauthorized()); + const dir = makeProjectDir(); + try { + authMocks.tryResolveCredential.mockResolvedValue({ + type: "oauth", + access_token: "host-token", + source: "env_oauth", + refreshable: false, + }); + vi.stubGlobal("fetch", fetchMock); + writeFileSync(join(dir, "index.html"), "", "utf-8"); + await expect(publishProjectArchive(dir)).rejects.toThrow( + "HEYGEN_ACCESS_TOKEN was rejected. Fix or unset it, then publish again.", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("publishProjectArchive with an expired login it can refresh", () => { diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index 8fd705a0203..339cbf7cbfc 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -7,7 +7,7 @@ import { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "@hyperframes/core"; import { buildAuthHeaders } from "../auth/client.js"; import { tryResolveCredential } from "../auth/index.js"; import { isAuthError } from "../auth/errors.js"; -import { isTokenExpired, type ResolvedCredential } from "../auth/resolver.js"; +import { envCredentialVar, isTokenExpired, type ResolvedCredential } from "../auth/resolver.js"; import { refreshIfNeeded } from "../cloud/auth.js"; import { writeProjectLink } from "./projectLink.js"; @@ -167,16 +167,9 @@ async function metadataRequestError(response: Response, fallback: string): Promi const LOGIN_EXPIRED = "Your login expired. Run hyperframes auth login, then publish again."; const LOGIN_CHANGED = "Your login changed during publish. Run publish again."; -export const API_KEY_ENV_VAR = { env: "HEYGEN_API_KEY", env_alias: "HYPERFRAMES_API_KEY" } as const; - function rejectedCredentialMessage(credential: ResolvedCredential): string { - if ( - credential.type === "api_key" && - (credential.source === "env" || credential.source === "env_alias") - ) { - return `${API_KEY_ENV_VAR[credential.source]} was rejected. Fix or unset it, then publish again.`; - } - return LOGIN_EXPIRED; + const envVar = envCredentialVar(credential.source); + return envVar ? `${envVar} was rejected. Fix or unset it, then publish again.` : LOGIN_EXPIRED; } /** Resolves the credential, or refreshes `checked` (a credential already resolved) without re-resolving. */ diff --git a/skills-manifest.json b/skills-manifest.json index 4655e215d69..e1089bd489b 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -30,7 +30,7 @@ "files": 7 }, "hyperframes-cli": { - "hash": "599eba83e1bf7245", + "hash": "b7f9a176ccf65fc7", "files": 11 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/references/cloud.md b/skills/hyperframes-cli/references/cloud.md index 0ba144583ef..4b76bc809c4 100644 --- a/skills/hyperframes-cli/references/cloud.md +++ b/skills/hyperframes-cli/references/cloud.md @@ -30,7 +30,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/credentials`. Point at a different backend with `HEYGEN_API_URL` (default `https://api.heygen.com`). +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 `~/.heygen/credentials`. Point at a different backend with `HEYGEN_API_URL` (default `https://api.heygen.com`). ## The render pipeline