feat(cli): use dedicated HyperFrames OAuth clients

This commit is contained in:
James
2026-10-28 14:12:47 -04:00
parent c569760a7a
commit 40d3312661
31 changed files with 559 additions and 134 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>
The credential lives in `~/.heygen/credentials` (mode `0600`) — no per-repo `.env` to manage. Browser OAuth is a `hyperframes auth login` feature. The separate [`heygen` CLI](https://github.com/heygen-com/heygen-cli) (its own install — there's no `npx heygen`) is API-key-only, so `heygen auth login` just stores a key you paste. Both read the same `~/.heygen/credentials`, so signing in with one carries to the other.
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.
<Tip>
No account is needed to try HyperFrames locally. With no credential, voice can
@@ -54,12 +54,11 @@ sound retrieval. It resolves first-match-wins:
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`)
5. `~/.hyperframes/credentials` — written by `hyperframes auth login`
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.
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.
## Providers used by agent workflows
@@ -70,7 +69,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` → `~/.heygen` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` |
| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` → `~/.hyperframes` · 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) |
@@ -145,7 +144,9 @@ to a shared space with `--space`.
| `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`). |
| `HYPERFRAMES_CONFIG_DIR` | HyperFrames credentials root (default `~/.hyperframes`). |
| `HYPERFRAMES_ENV` | `prod` (default) or `dev`; switches OAuth and API defaults together. Dev credentials live under the root’s `dev/` directory. |
| `HYPERFRAMES_OAUTH_CLIENT_ID` | Override the public client ID for a controlled OAuth environment. |
| `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`. |
| `GOOGLE_APPLICATION_CREDENTIALS` / `GCS_CREDS` | Gemini TTS service-account JSON file path / injected JSON. Used when neither API key is set. |
+12 -9
View File
@@ -682,10 +682,11 @@ and are copied to `~/.media/` so other projects can reuse them. Sound effects
under CC BY get their credit written to `.media/audio/sfx/CREDITS.md`.
**What needs an account, and what can cost money.** The HeyGen sources run
through the separate `heygen` CLI, signed in with `heygen auth login --oauth`.
Searching the music, image, and icon catalogs is free. Text to speech and avatar
video draw on a free allowance when you are signed in with OAuth, then on your
HeyGen billing, so an agent should ask before generating them. When a
through the separate `heygen` executable using the login selected by
`hyperframes auth login`. It receives a temporary access-token credential and
cannot read the separate HeyGen login or refresh a HyperFrames grant.
The server decides feature eligibility and charges. An explicit API-key credential
uses API-key billing. When a
host app sets `HEYGEN_API_BASE` and `HEYGEN_API_KEY`, HeyGen calls go through that
host instead and are charged to its key. The `codex` image fallback uses your
ChatGPT subscription. Openverse, theSVG, GitHub, favicons, color grades, and the
@@ -2152,16 +2153,16 @@ usage event (`--skill`, `--event`, `--outcome`). Nothing calls it by hand.
## hyperframes auth
Sign in to HeyGen and manage credentials. They live in `~/.heygen/credentials`
(mode `0600`) and are **shared with the `heygen` CLI** — sign in with one and
the other picks up the session.
Sign into your HeyGen account through the dedicated HyperFrames OAuth client.
Credentials live in `~/.hyperframes/credentials` (mode `0600`), independently
of the `heygen` CLI. A previous shared login requires a fresh HyperFrames sign-in.
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. `~/.heygen/credentials`
4. `~/.hyperframes/credentials`
### `auth login`
@@ -2221,7 +2222,9 @@ hyperframes auth logout --yes # no prompt
| `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`). |
| `HYPERFRAMES_CONFIG_DIR` | Credentials root (default `~/.hyperframes`). |
| `HYPERFRAMES_ENV` | `prod` (default) or `dev`; selects matching OAuth/API hosts and a separate dev credential directory. |
| `HYPERFRAMES_OAUTH_CLIENT_ID` | Override the selected public OAuth client. |
For the keys other capabilities use — ElevenLabs and Gemini for voice and music
fallback, OpenRouter and Gemini for capture — and how the skills prioritize
+1
View File
@@ -18,6 +18,7 @@ for (const name of [
"CODEX_HOME",
"CLAUDE_CONFIG_DIR",
"HEYGEN_CONFIG_DIR",
"HYPERFRAMES_CONFIG_DIR",
"HYPERFRAMES_CATALOG_ARTIFACT_DIR",
"HYPERFRAMES_MEDIA_HOME",
"HF_HOME",
+9 -2
View File
@@ -14,9 +14,16 @@ const ENV_KEYS = [
"HEYGEN_API_KEY",
"HEYGEN_ACCESS_TOKEN",
"HYPERFRAMES_API_KEY",
"HYPERFRAMES_CONFIG_DIR",
"HEYGEN_CONFIG_DIR",
"HEYGEN_API_BASE",
"HEYGEN_API_URL",
"HYPERFRAMES_OAUTH_CLIENT_ID",
"HYPERFRAMES_ENV",
"HYPERFRAMES_OAUTH_TOKEN_URL",
"HYPERFRAMES_OAUTH_AUTHORIZE_URL",
"HYPERFRAMES_OAUTH_REVOKE_URL",
"HYPERFRAMES_OAUTH_DEVICE_URL",
] as const;
type EnvKey = (typeof ENV_KEYS)[number];
@@ -30,7 +37,7 @@ export interface EnvFixture {
/**
* Take a snapshot of the auth-related env, clear them, make a tmp
* `HEYGEN_CONFIG_DIR`, and return a `restore()` that undoes all of
* `HYPERFRAMES_CONFIG_DIR`, and return a `restore()` that undoes all of
* the above.
*/
export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFixture> {
@@ -40,7 +47,7 @@ export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFix
saved[k] = process.env[k];
delete process.env[k];
}
process.env["HEYGEN_CONFIG_DIR"] = dir;
process.env["HYPERFRAMES_CONFIG_DIR"] = dir;
const restore = async (): Promise<void> => {
for (const k of ENV_KEYS) {
+4 -2
View File
@@ -19,8 +19,8 @@ import { ErrApi, ErrUnauthenticated, isAuthError } from "./errors.js";
import type { ResolvedCredential } from "./resolver.js";
import { scrubCredentials } from "./scrub.js";
import type { OAuthTokens } from "./store.js";
import { oauthEnvironment } from "./config.js";
const DEFAULT_BASE_URL = "https://api.heygen.com";
export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source";
export const HEYGEN_CLI_SOURCE = "cli";
// Tool-attribution header identifying this CLI as the request origin. Sent on
@@ -32,7 +32,9 @@ export const HEYGEN_CLIENT_SOURCE = "hyperframes";
export function apiBaseUrl(): string {
const override = process.env["HEYGEN_API_URL"];
return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL;
return override && override.length > 0
? override.replace(/\/+$/, "")
: `https://api.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com`;
}
export type BillingType = "wallet" | "subscription" | "usage_based" | string;
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
import {
assertOAuthIdentity,
authorizeEndpoint,
deviceAuthorizationEndpoint,
oauthIdentity,
resolveClientId,
revokeEndpoint,
tokenEndpoint,
} from "./config.js";
import { apiBaseUrl } from "./client.js";
describe("HyperFrames OAuth configuration", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
beforeEach(async () => {
fixture = await setupTempAuthEnv();
});
afterEach(async () => {
await fixture.restore();
});
it("uses the dedicated public production client", () => {
expect(resolveClientId()).toBe("Zg3LF4FCLoeJoGs2YqMtZ17g");
expect(authorizeEndpoint()).toBe("https://app.heygen.com/oauth/authorize");
expect(tokenEndpoint()).toBe("https://api2.heygen.com/v1/oauth/token");
expect(apiBaseUrl()).toBe("https://api.heygen.com");
});
it("switches every default host together for development", () => {
process.env["HYPERFRAMES_ENV"] = "dev";
expect(resolveClientId()).toBe("9QoaiwCZov9O0cAkC4OZbR4p");
expect(authorizeEndpoint()).toBe("https://api2.dev.heygen.com/v1/oauth/authorize");
expect(tokenEndpoint()).toBe("https://api2.dev.heygen.com/v1/oauth/token");
expect(revokeEndpoint()).toBe("https://api2.dev.heygen.com/v1/oauth/revoke");
expect(deviceAuthorizationEndpoint()).toBe(
"https://api2.dev.heygen.com/v1/oauth/device_authorization",
);
expect(apiBaseUrl()).toBe("https://api.dev.heygen.com");
});
it("rejects a session after either its client or token server changes", () => {
const identity = oauthIdentity();
expect(() => assertOAuthIdentity(identity)).not.toThrow();
process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "another-client";
expect(() => assertOAuthIdentity(identity)).toThrow("different OAuth client or environment");
delete process.env["HYPERFRAMES_OAUTH_CLIENT_ID"];
process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] = "https://other.example/token";
expect(() => assertOAuthIdentity(identity)).toThrow("different OAuth client or environment");
});
});
+65
View File
@@ -0,0 +1,65 @@
import { AuthError, ErrOAuthNotConfigured } from "./errors.js";
const PROD_CLIENT_ID = "Zg3LF4FCLoeJoGs2YqMtZ17g";
export function oauthEnvironment(): "prod" | "dev" {
const value = process.env["HYPERFRAMES_ENV"] || "prod";
if (value === "prod" || value === "dev") return value;
throw new AuthError("OAUTH_NOT_CONFIGURED", "HYPERFRAMES_ENV must be prod or dev");
}
export function resolveClientId(): string {
const id =
process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] ||
(oauthEnvironment() === "prod" ? PROD_CLIENT_ID : "9QoaiwCZov9O0cAkC4OZbR4p");
if (!id) throw ErrOAuthNotConfigured();
return id;
}
export function tokenEndpoint(): string {
return (
process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] ||
`https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com/v1/oauth/token`
);
}
export function authorizeEndpoint(): string {
return (
process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] ||
(oauthEnvironment() === "dev"
? "https://api2.dev.heygen.com/v1/oauth/authorize"
: "https://app.heygen.com/oauth/authorize")
);
}
export function revokeEndpoint(): string {
return (
process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] ||
`https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com/v1/oauth/revoke`
);
}
export function deviceAuthorizationEndpoint(): string {
return (
process.env["HYPERFRAMES_OAUTH_DEVICE_URL"] ||
`https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com/v1/oauth/device_authorization`
);
}
export function oauthIdentity(): { client_id: string; token_endpoint: string } {
return { client_id: resolveClientId(), token_endpoint: tokenEndpoint() };
}
export function assertOAuthIdentity(tokens: { client_id?: string; token_endpoint?: string }): void {
const identity = oauthIdentity();
if (
tokens.client_id !== identity.client_id ||
tokens.token_endpoint !== identity.token_endpoint
) {
throw new AuthError(
"LOGIN_EXPIRED",
"This login belongs to a different OAuth client or environment",
"Run `hyperframes auth login` to sign into the selected HyperFrames environment.",
);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ export const ErrInvalidStore = (detail: string) =>
new AuthError(
"INVALID_STORE",
`Credential file is unreadable: ${detail}`,
"Delete ~/.heygen/credentials and run `hyperframes auth login` to re-create it.",
"Delete ~/.hyperframes/credentials and run `hyperframes auth login` to re-create it.",
);
export const ErrUnauthenticated = (detail?: string) =>
@@ -0,0 +1,78 @@
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 { persistFreshOAuth } from "./oauth.js";
import { withMediaAuthEnvironment } from "./media-environment.js";
describe("media-use authentication", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
beforeEach(async () => {
fixture = await setupTempAuthEnv();
});
afterEach(async () => {
await fixture.restore();
});
it("passes a fresh access token through a private, temporary Go credential file", async () => {
await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" });
let path = "";
await withMediaAuthEnvironment(async (env) => {
path = join(env.HEYGEN_CONFIG_DIR!, "credentials");
expect(env.HEYGEN_ACCESS_TOKEN).toBe("hf-access");
expect(env.HEYGEN_API_BASE).toBe("https://api.heygen.com");
expect(env.HEYGEN_API_KEY).toBeUndefined();
const copied = JSON.parse(await fs.readFile(path, "utf8"));
expect(copied.oauth.access_token).toBe("hf-access");
expect(copied.oauth.refresh_token).toBeUndefined();
expect((await fs.stat(path)).mode & 0o777).toBe(0o600);
expect((await fs.stat(env.HEYGEN_CONFIG_DIR!)).mode & 0o777).toBe(0o700);
});
await expect(fs.stat(path)).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(join(fixture.dir, "credentials"), "utf8")).toContain("hf-refresh");
});
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";
try {
await expect(withMediaAuthEnvironment(() => undefined)).rejects.toThrow(
"requires its own API credential",
);
} finally {
delete process.env["HEYGEN_API_BASE"];
}
});
it("isolates an anonymous operation from an existing HeyGen login", async () => {
process.env["HEYGEN_CONFIG_DIR"] = join(fixture.dir, "heygen");
await fs.mkdir(process.env["HEYGEN_CONFIG_DIR"]);
const legacyPath = join(process.env["HEYGEN_CONFIG_DIR"], "credentials");
await fs.writeFile(legacyPath, "hg_other_wallet");
await withMediaAuthEnvironment(async (env) => {
expect(env.HEYGEN_CONFIG_DIR).not.toBe(process.env["HEYGEN_CONFIG_DIR"]);
expect(await fs.readdir(env.HEYGEN_CONFIG_DIR!)).toEqual([]);
expect(env.HEYGEN_API_KEY).toBeUndefined();
});
expect(await fs.readFile(legacyPath, "utf8")).toBe("hg_other_wallet");
});
it("preserves an explicit host gateway and cleans up after a subprocess error", async () => {
process.env["HEYGEN_API_BASE"] = "https://gateway.example";
process.env["HEYGEN_API_KEY"] = "gateway-key";
let dir = "";
try {
await expect(
withMediaAuthEnvironment((env) => {
dir = env.HEYGEN_CONFIG_DIR!;
expect(env.HEYGEN_API_BASE).toBe("https://gateway.example");
expect(env.HEYGEN_API_KEY).toBe("gateway-key");
throw new Error("subprocess failed");
}),
).rejects.toThrow("subprocess failed");
await expect(fs.stat(dir)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
delete process.env["HEYGEN_API_BASE"];
}
});
});
@@ -0,0 +1,62 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
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 { writeStore } from "./store.js";
/** The Go CLI accepts OAuth through a credential file, but cannot refresh a HyperFrames grant. */
export async function withMediaAuthEnvironment<T>(run: (env: NodeJS.ProcessEnv) => T): Promise<T> {
let credential;
try {
credential = await tryResolveCredential();
} catch (error) {
// An expired login must not stop anonymous/local providers or select another wallet.
if (!isAuthError(error) || error.code !== "LOGIN_EXPIRED") throw error;
}
if (credential?.type === "oauth" && credential.refreshable && credential.refresh_token) {
await refreshTokens(credential.refresh_token);
credential = await resolveCredential();
}
const dir = await fs.mkdtemp(join(tmpdir(), "hyperframes-media-auth-"));
try {
await fs.chmod(dir, 0o700);
const env: NodeJS.ProcessEnv = { ...process.env, HEYGEN_CONFIG_DIR: dir };
env.HEYGEN_API_BASE ||= apiBaseUrl();
if (credential?.type === "oauth") {
const host = new URL(env.HEYGEN_API_BASE).hostname;
if (host !== "heygen.com" && !host.endsWith(".heygen.com")) {
throw new AuthError(
"OAUTH_NOT_CONFIGURED",
"A media gateway requires its own API credential",
);
}
delete env.HEYGEN_API_KEY;
delete env.HYPERFRAMES_API_KEY;
env.HEYGEN_ACCESS_TOKEN = credential.access_token;
await writeStore(
{
oauth: {
access_token: credential.access_token,
expires_at: credential.expires_at?.toISOString(),
token_type: "Bearer",
},
},
join(dir, "credentials"),
);
} else if (credential?.type === "api_key") {
env.HEYGEN_API_KEY = credential.key;
delete env.HEYGEN_ACCESS_TOKEN;
} else {
delete env.HEYGEN_ACCESS_TOKEN;
delete env.HEYGEN_API_KEY;
delete env.HYPERFRAMES_API_KEY;
}
return await run(env);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
+48 -2
View File
@@ -1,3 +1,4 @@
import { oauthIdentity } from "./config.js";
import { promises as fs } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
@@ -12,7 +13,7 @@ import {
startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
} from "./oauth.js";
import { readStore, writeStore } from "./store.js";
import { readStore, writeStore as writeUnboundStore, type Credentials } from "./store.js";
// Mock the interactive bits so startAuthorizationCodeFlow runs headless.
vi.mock("./loopback.js", () => ({
@@ -68,6 +69,13 @@ async function loginWithTokens(body: Record<string, unknown>) {
return (await readStore()).credentials;
}
async function writeStore(credentials: Credentials): Promise<void> {
await writeUnboundStore({
...credentials,
...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}),
});
}
describe("auth/oauth", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
@@ -170,6 +178,26 @@ describe("auth/oauth", () => {
});
describe("refreshTokens", () => {
it.each([undefined, "foreign-client"])(
"does not send a refresh token with client binding %s",
async (client_id) => {
await writeUnboundStore({
oauth: {
access_token: "at",
refresh_token: "rt",
client_id,
token_endpoint: oauthIdentity().token_endpoint,
},
});
const fetchImpl = vi.fn<typeof fetch>();
await expect(refreshTokens("rt", { fetchImpl })).rejects.toMatchObject({
code: "LOGIN_EXPIRED",
});
expect(fetchImpl).not.toHaveBeenCalled();
expect((await readStore()).credentials.oauth?.refresh_token).toBe("rt");
},
);
it("posts grant_type=refresh_token and persists the response", async () => {
process.env["HEYGEN_API_URL"] = "https://api.test.example";
await writeStore({ oauth: { access_token: "old_at", refresh_token: "old_rt" } });
@@ -196,7 +224,7 @@ describe("auth/oauth", () => {
// Should have persisted.
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
expect(credentials.oauth).toMatchObject({ ...oauthIdentity(), access_token: "new_at" });
});
it("preserves the prior refresh_token when the server omits it (no rotation)", async () => {
@@ -264,6 +292,7 @@ describe("auth/oauth", () => {
path,
JSON.stringify({
oauth: {
...oauthIdentity(),
access_token: "old_at",
refresh_token: "keep_me_rt",
id_token: "future_id_token_value",
@@ -281,6 +310,7 @@ describe("auth/oauth", () => {
});
it("throws REFRESH_FAILED on 400/401", async () => {
await writeStore({ oauth: { access_token: "at", refresh_token: "bad_rt" } });
const fetchImpl = (async () =>
new Response("invalid_grant", {
status: 400,
@@ -291,6 +321,7 @@ describe("auth/oauth", () => {
});
it("throws API_ERROR on 5xx", async () => {
await writeStore({ oauth: { access_token: "at", refresh_token: "rt" } });
const fetchImpl = (async () =>
new Response("upstream", { status: 503 })) as unknown as typeof fetch;
await expect(refreshTokens("rt", { fetchImpl })).rejects.toSatisfy((err) => {
@@ -300,6 +331,20 @@ describe("auth/oauth", () => {
});
describe("revokeTokens", () => {
it("does not revoke another client's stored grant", async () => {
await writeUnboundStore({
oauth: {
access_token: "foreign-at",
refresh_token: "foreign-rt",
client_id: "another-client",
token_endpoint: oauthIdentity().token_endpoint,
},
});
const fetchImpl = vi.fn<typeof fetch>();
await revokeTokens("foreign-rt", { fetchImpl });
expect(fetchImpl).not.toHaveBeenCalled();
});
it("never throws on network failure (best-effort)", async () => {
const fetchImpl = (async () => {
throw new Error("connection refused");
@@ -348,6 +393,7 @@ describe("auth/oauth", () => {
describe("error scrubbing", () => {
it("refreshTokens does not leak token-shaped secrets from the error body", async () => {
await writeStore({ oauth: { access_token: "at", refresh_token: "rt_leak_secret" } });
const fetchImpl = (async () =>
new Response(
'{"error":"invalid_grant","echoed":"refresh_token=rt_leak_secret&code_verifier=cv_leak"}',
+25 -35
View File
@@ -38,7 +38,6 @@ import {
ErrApi,
ErrDeviceAuthFailed,
ErrLoginChanged,
ErrOAuthNotConfigured,
ErrRefreshFailed,
isAuthError,
} from "./errors.js";
@@ -55,25 +54,25 @@ import {
type StoredUserInfo,
} from "./store.js";
import { c } from "../ui/colors.js";
import {
assertOAuthIdentity,
authorizeEndpoint,
deviceAuthorizationEndpoint,
oauthIdentity,
resolveClientId,
revokeEndpoint,
tokenEndpoint,
} from "./config.js";
export { resolveClientId } from "./config.js";
const REVOKE_TIMEOUT_MS = 5_000;
const MIN_EXPIRES_IN_SECONDS = 30;
/**
* Default OAuth client_id baked at build time. Override with the
* `HYPERFRAMES_OAUTH_CLIENT_ID` env var. Empty string means "not
* configured" — `resolveClientId()` errors cleanly with a pointer
* at `--api-key`.
*/
const DEFAULT_CLIENT_ID = "q2A2QRSke2LrFTPJhoDbHtXh";
const DEFAULT_SCOPES = "openid profile email";
// Endpoint defaults — see file-header comment for why these straddle two
// hosts. Each is independently overridable.
const DEFAULT_AUTHORIZE_URL = "https://app.heygen.com/oauth/authorize";
const DEFAULT_TOKEN_URL = "https://api2.heygen.com/v1/oauth/token";
const DEFAULT_REVOKE_URL = "https://api2.heygen.com/v1/oauth/revoke";
const DEFAULT_DEVICE_AUTHORIZATION_URL = "https://api2.heygen.com/v1/oauth/device_authorization";
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
const MAX_DEVICE_FLOW_SECONDS = 30 * 60;
const MIN_DEVICE_POLL_SECONDS = 5;
@@ -81,19 +80,6 @@ const MAX_DEVICE_POLL_SECONDS = 60;
const MAX_DEVICE_RESPONSE_BYTES = 64 * 1024;
const DEVICE_REQUEST_TIMEOUT_MS = 15_000;
function authorizeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL;
}
function tokenEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] || DEFAULT_TOKEN_URL;
}
function revokeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL;
}
function deviceAuthorizationEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_DEVICE_URL"] || DEFAULT_DEVICE_AUTHORIZATION_URL;
}
export interface AuthorizeFlowOptions {
/** Override scopes (default `openid profile email`). */
scope?: string;
@@ -134,14 +120,6 @@ export interface DeviceAuthorizationFlowOptions {
onChallenge?: (challenge: DeviceAuthorizationChallenge) => void | Promise<void>;
}
/** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */
export function resolveClientId(): string {
const override = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"];
const id = override && override.length > 0 ? override : DEFAULT_CLIENT_ID;
if (!id || id.length === 0) throw ErrOAuthNotConfigured();
return id;
}
/**
* For command-entry points: throw the standard `ErrOAuthNotConfigured`
* via `resolveClientId()`; on a real misconfig, print a friendly hint
@@ -385,6 +363,9 @@ export async function refreshTokens(
refresh_token: string,
opts: RefreshOptions = {},
): Promise<OAuthTokens> {
const { credentials } = await readStore();
if (credentials.oauth?.refresh_token !== refresh_token) throw ErrLoginChanged();
assertOAuthIdentity(credentials.oauth);
const clientId = resolveClientId();
const fetchImpl = opts.fetchImpl ?? fetch;
const body = new URLSearchParams({
@@ -432,6 +413,13 @@ export async function revokeTokens(token: string, opts: RevokeOptions = {}): Pro
let clientId: string;
try {
clientId = resolveClientId();
const { credentials } = await readStore();
if (
credentials.oauth &&
(credentials.oauth.access_token === token || credentials.oauth.refresh_token === token)
) {
assertOAuthIdentity(credentials.oauth);
}
} catch {
// OAuth not configured — nothing to revoke server-side.
return;
@@ -622,7 +610,9 @@ async function persistOAuth(tokens: OAuthTokens, opts: { refreshed?: string } =
throw ErrLoginChanged();
}
const oauth: OAuthTokens =
opts.refreshed !== undefined ? { ...existing.oauth, ...tokens } : { ...tokens };
opts.refreshed !== undefined
? { ...existing.oauth, ...tokens, ...oauthIdentity() }
: { ...tokens, ...oauthIdentity() };
// Start from the existing record so co-located data survives: the
// api_key, the friendly-display `user` block, AND any unknown/foreign
// keys another CLI wrote (carried on a hidden symbol slot by spread).
@@ -656,7 +646,7 @@ export async function persistVerifiedOAuthSession(
}
const next: Credentials = {
...credentials,
oauth: { ...tokens },
oauth: { ...tokens, ...oauthIdentity() },
};
if (user.email || user.first_name || user.last_name || user.username) {
// Preserve only unknown/foreign user fields from the existing record.
+20 -12
View File
@@ -1,32 +1,40 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
import { CREDENTIAL_FILENAME, configDir, credentialPath } from "./paths.js";
describe("auth/paths", () => {
const original = process.env["HEYGEN_CONFIG_DIR"];
const original = process.env["HYPERFRAMES_CONFIG_DIR"];
beforeEach(() => {
delete process.env["HEYGEN_CONFIG_DIR"];
delete process.env["HYPERFRAMES_CONFIG_DIR"];
});
afterEach(() => {
if (original !== undefined) process.env["HEYGEN_CONFIG_DIR"] = original;
else delete process.env["HEYGEN_CONFIG_DIR"];
vi.unstubAllEnvs();
if (original !== undefined) process.env["HYPERFRAMES_CONFIG_DIR"] = original;
else delete process.env["HYPERFRAMES_CONFIG_DIR"];
});
it("defaults to ~/.heygen", () => {
expect(configDir()).toBe(join(homedir(), ".heygen"));
it("defaults to ~/.hyperframes", () => {
expect(configDir()).toBe(join(homedir(), ".hyperframes"));
});
it("honors HEYGEN_CONFIG_DIR override", () => {
process.env["HEYGEN_CONFIG_DIR"] = "/tmp/some-test-dir";
it("honors HYPERFRAMES_CONFIG_DIR override", () => {
process.env["HYPERFRAMES_CONFIG_DIR"] = "/tmp/some-test-dir";
expect(configDir()).toBe("/tmp/some-test-dir");
expect(credentialPath()).toBe(join("/tmp/some-test-dir", CREDENTIAL_FILENAME));
});
it("treats empty HEYGEN_CONFIG_DIR as unset", () => {
process.env["HEYGEN_CONFIG_DIR"] = "";
expect(configDir()).toBe(join(homedir(), ".heygen"));
it("treats empty HYPERFRAMES_CONFIG_DIR as unset", () => {
process.env["HYPERFRAMES_CONFIG_DIR"] = "";
expect(configDir()).toBe(join(homedir(), ".hyperframes"));
});
it("ignores the HeyGen CLI directory and isolates the dev environment", () => {
vi.stubEnv("HEYGEN_CONFIG_DIR", "/tmp/heygen-other-account");
expect(configDir()).toBe(join(homedir(), ".hyperframes"));
vi.stubEnv("HYPERFRAMES_ENV", "dev");
expect(configDir()).toBe(join(homedir(), ".hyperframes", "dev"));
});
});
+5 -9
View File
@@ -1,23 +1,19 @@
/**
* Filesystem layout for the shared HeyGen credential store. Mirrors
* `heygen-cli/internal/paths/paths.go` so both CLIs read the same file.
* `HEYGEN_CONFIG_DIR` overrides the directory.
* HyperFrames credentials are isolated from the HeyGen CLI and between environments.
*/
import { homedir } from "node:os";
import { join } from "node:path";
import { oauthEnvironment } from "./config.js";
/**
* Filename for the credential store. Matches heygen-cli (no `.json`
* suffix) so a `~/.heygen/credentials` written by either CLI is
* readable by the other — see `heygen-cli/internal/auth/file_resolver.go`.
* Filename for the HyperFrames credential store (JSON, no extension).
*/
export const CREDENTIAL_FILENAME = "credentials";
export function configDir(): string {
const override = process.env["HEYGEN_CONFIG_DIR"];
if (override && override.length > 0) return override;
return join(homedir(), ".heygen");
const root = process.env["HYPERFRAMES_CONFIG_DIR"] || join(homedir(), ".hyperframes");
return oauthEnvironment() === "dev" ? join(root, "dev") : root;
}
export function credentialPath(): string {
+18 -5
View File
@@ -1,10 +1,18 @@
import { oauthIdentity } from "./config.js";
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 { writeStore } from "./store.js";
import { writeStore as writeUnboundStore, type Credentials } from "./store.js";
async function writeStore(credentials: Credentials): Promise<void> {
await writeUnboundStore({
...credentials,
...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}),
});
}
describe("auth/resolver", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
@@ -102,15 +110,20 @@ describe("auth/resolver", () => {
if (r.type === "oauth") expect(r.refreshable).toBe(true);
});
it("skips expired non-refreshable oauth and falls through to api_key", async () => {
it("refuses an expired OAuth login without switching to the stored API wallet", async () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
api_key: "fallback",
oauth: { access_token: "stale-at", expires_at: past },
});
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") expect(r.key).toBe("fallback");
await expect(resolveCredential()).rejects.toMatchObject({ code: "LOGIN_EXPIRED" });
});
it("requires a new login for an unbound or foreign OAuth credential", async () => {
await writeUnboundStore({ api_key: "fallback", oauth: { access_token: "legacy" } });
await expect(resolveCredential()).rejects.toMatchObject({ code: "LOGIN_EXPIRED" });
await writeStore({ oauth: { access_token: "foreign", client_id: "shared-heygen-cli" } });
await expect(resolveCredential()).rejects.toMatchObject({ code: "LOGIN_EXPIRED" });
});
it("reports an expired login with no refresh token as expired, not as never signed in", async () => {
+7 -4
View File
@@ -1,11 +1,11 @@
/**
* Chain resolver for HeyGen credentials.
* Chain resolver for 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. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key
* 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.
@@ -13,11 +13,12 @@
* Expiry policy: an OAuth access_token whose `expires_at` is in the
* past (60s skew) is considered expired. If a `refresh_token` is also
* present, callers can still use it via `refreshable: true`. Otherwise
* the api_key (if any) wins, else `ErrLoginExpired`, not `ErrNotConfigured`.
* session requires sign-in; a saved API key never replaces expired OAuth.
*/
import { isHeaderSafe, readStore } from "./store.js";
import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js";
import { assertOAuthIdentity } from "./config.js";
type EnvSource = "env" | "env_alias" | "env_oauth";
type CredentialSource = EnvSource | "file_json" | "file_legacy";
@@ -79,12 +80,14 @@ export async function resolveCredential(opts: ResolveOptions = {}): Promise<Reso
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 };
}
throw credentials.oauth ? ErrLoginExpired() : ErrNotConfigured();
throw ErrNotConfigured();
}
function headerSafeEnv(name: string): string | undefined {
+12 -6
View File
@@ -1,6 +1,6 @@
/**
* Read/write the shared `~/.heygen/credentials` file (JSON contents,
* no `.json` extension — the path matches heygen-cli).
* Read/write the isolated `~/.hyperframes/credentials` file (JSON contents,
* no `.json` extension).
*
* Current format:
* {
@@ -26,10 +26,8 @@
*
* Writes go to a temp file + rename, 0600 mode, parent dir 0700.
*
* Cross-CLI forward compatibility: this file is SHARED with the Go
* `heygen` CLI (and any future tool). Either CLI may write keys this
* version doesn't model yet. To avoid one CLI silently clobbering the
* other's data on round-trip, the reader stashes every unrecognized
* Forward compatibility: future HyperFrames versions may write keys this
* version does not model. To preserve them on round-trip, the reader stashes every unrecognized
* top-level key (and every unrecognized key inside the `oauth` / `user`
* sub-objects) into a hidden passthrough bag, and the writer re-emits
* them verbatim. Known fields are still strictly validated; the
@@ -70,6 +68,8 @@ const KNOWN_OAUTH_KEYS = new Set([
"expires_at",
"scope",
"token_type",
"client_id",
"token_endpoint",
]);
/** Keys this CLI version models inside the `user` sub-object. */
const KNOWN_USER_KEYS = new Set(["email", "first_name", "last_name", "username"]);
@@ -81,6 +81,8 @@ export interface OAuthTokens {
expires_at?: string;
scope?: string;
token_type?: string;
client_id?: string;
token_endpoint?: string;
/** Unknown/future keys captured for cross-CLI round-trip. */
[UNKNOWN]?: Record<string, unknown>;
}
@@ -284,6 +286,8 @@ function assignOptionalStrings(
}
const OAUTH_OPTIONAL: readonly [string, FieldPicker][] = [
["client_id", "header_safe"],
["token_endpoint", "non_empty"],
["refresh_token", "header_safe"],
["expires_at", "non_empty"],
["scope", "non_empty"],
@@ -433,6 +437,8 @@ function serializeOAuth(o: OAuthTokens): Record<string, unknown> {
if (o.expires_at) oauth["expires_at"] = o.expires_at;
if (o.scope) oauth["scope"] = o.scope;
if (o.token_type) oauth["token_type"] = o.token_type;
if (o.client_id) oauth["client_id"] = o.client_id;
if (o.token_endpoint) oauth["token_endpoint"] = o.token_endpoint;
return oauth;
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Persistence + display helpers for the friendly-display `user` block in
* the shared `~/.heygen/credentials` file.
* the HyperFrames `~/.hyperframes/credentials` file.
*
* The `user` block is additive METADATA captured at login time from
* `GET /v3/users/me` — NOT a credential. It lets `auth status` (and the
+3 -2
View File
@@ -29,7 +29,7 @@ const HELP = `
${c.bold("hyperframes auth")} ${c.dim("<subcommand> [args]")}
Manage HeyGen credentials. Credentials live in
${c.accent("~/.heygen/credentials")} and are shared with heygen-cli.
${c.accent("~/.hyperframes/credentials")} and are shared with heygen-cli.
${c.bold("SUBCOMMANDS:")}
${c.accent("login")} ${c.dim("Sign in via browser, --device for SSH, or --api-key for a long-lived key.")}
@@ -42,7 +42,8 @@ ${c.bold("ENV VARS:")}
${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_API_URL")} Override the API base URL (default https://api.heygen.com).
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen).
${c.accent("HYPERFRAMES_ENV")} prod (default) or dev; selects OAuth/API hosts together.
${c.accent("HYPERFRAMES_CONFIG_DIR")} Override the credentials directory (default ~/.hyperframes).
${c.accent("HYPERFRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test).
${c.accent("HYPERFRAMES_OAUTH_DEVICE_URL")} Override the RFC 8628 device endpoint (for dev/test).
`;
+2 -2
View File
@@ -69,8 +69,8 @@ function warnIfEnvCredentialActive(): void {
async function ensureConfirmed(yes: boolean, keepApiKey: boolean): Promise<boolean> {
if (yes) return true;
const prompt = keepApiKey
? `This will sign out of any active OAuth session on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `
: `This will sign out of HeyGen on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `;
? `This will sign out of any active OAuth session on this machine (~/.hyperframes lives at ${configDir()}). Continue? [y/N] `
: `This will sign out of HeyGen on this machine (~/.hyperframes lives at ${configDir()}). Continue? [y/N] `;
return confirmInteractive(prompt);
}
+7 -7
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { finishCommand } from "../utils/commandResult.js";
import { withMediaAuthEnvironment } from "../auth/media-environment.js";
const MEDIA_USE_ARGS = {
type: { type: "string" },
@@ -68,18 +69,17 @@ export function mediaUseVerbFlags(verb: MediaUseVerb): string[] {
return verb === "resolve" ? [] : [`--${verb}`];
}
type InvokeMediaUse = (verb: MediaUseVerb) => never;
type InvokeMediaUse = (verb: MediaUseVerb) => never | Promise<never>;
function invokeEngine(verb: MediaUseVerb): never {
async function invokeEngine(verb: MediaUseVerb): Promise<never> {
const here = dirname(fileURLToPath(import.meta.url));
const passed = mediaUsePassthroughArgs(process.argv);
const flag = mediaUseVerbFlags(verb);
const result = spawnSync(
process.execPath,
[resolveMediaUseEnginePath(here), ...flag, ...passed],
{
const result = await withMediaAuthEnvironment((env) =>
spawnSync(process.execPath, [resolveMediaUseEnginePath(here), ...flag, ...passed], {
stdio: "inherit",
},
env,
}),
);
if (result.error) throw result.error;
finishCommand(result.status ?? 1);
+14 -3
View File
@@ -12,9 +12,20 @@ vi.mock("../utils/publishProject.js", async (importOriginal) => ({
import publishCommand, { examples, parseUpdateTarget } from "./publish.js";
import { ensureProjectId } from "../utils/projectLink.js";
import { writeStore } from "../auth/store.js";
import { writeStore as writeUnboundStore, type Credentials } from "../auth/store.js";
import { oauthIdentity } from "../auth/config.js";
import { consumeCommandResult } from "../utils/commandResult.js";
async function writeStore(credentials: Credentials, path?: string): Promise<void> {
await writeUnboundStore(
{
...credentials,
...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}),
},
path,
);
}
const VISIBLE_INDEX = `<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"><div class="clip" data-start="0" data-duration="5">Visible</div></div></body></html>`;
function mockPublished(result: Record<string, unknown>): void {
@@ -193,7 +204,7 @@ describe("publish --update / --space ownership preflight", () => {
const project = mkdtempSync(join(tmpdir(), "hf-publish-owner-"));
const config = mkdtempSync(join(tmpdir(), "hf-publish-owner-config-"));
writeFileSync(join(project, "index.html"), VISIBLE_INDEX);
vi.stubEnv("HEYGEN_CONFIG_DIR", config);
vi.stubEnv("HYPERFRAMES_CONFIG_DIR", config);
vi.stubEnv("HEYGEN_API_KEY", credentials.env ?? "");
vi.stubEnv("HYPERFRAMES_API_KEY", "");
if (credentials.stored) await writeStore(credentials.stored, join(config, "credentials"));
@@ -352,7 +363,7 @@ describe("publish --update / --space ownership preflight", () => {
expect(stored?.credentials.oauth).toMatchObject(loginB);
expect(stored?.credentials.user?.email).toBe("b@example.com");
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual(["https://auth.test/token"]);
expect(fetchMock).not.toHaveBeenCalled();
expect(exitCode).toBe(1);
expect(errors.join("\n")).toContain("Your login changed during publish. Run publish again.");
} finally {
@@ -10,8 +10,8 @@ export const HEYGEN_MIN_VERSION = "0.3.0";
// installer text out of the runtime module; the docs are the safer source of
// truth for platform-specific setup.
export const HEYGEN_INSTALL_COMMAND =
"Install the CLI from https://developers.heygen.com/cli, then run: heygen auth login --oauth";
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
"Install the CLI from https://developers.heygen.com/cli, then run: hyperframes auth login";
export const HEYGEN_AUTH_COMMAND = "hyperframes auth login";
export const HEYGEN_UPDATE_COMMAND = "heygen update";
export const HEYGEN_CLIENT_SOURCE_ARGV = ["--headers", "X-HeyGen-Client-Source: media-use"];
@@ -738,6 +738,7 @@ beforeEach(() => {
linkMocks.writeProjectLink.mockReset();
vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", "");
vi.stubEnv("HEYGEN_API_URL", "");
vi.stubEnv("HYPERFRAMES_ENV", "prod");
});
afterEach(() => {
@@ -750,6 +751,11 @@ const signedStagedS3Url =
"https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption";
describe("publishProjectArchive", () => {
it("uses the development publish service with the development login", () => {
vi.stubEnv("HYPERFRAMES_ENV", "dev");
expect(getPublishApiBaseUrl()).toBe("https://api2.dev.heygen.com");
});
it("uploads through the staged publish flow and returns the stable project URL", async () => {
const dir = makeProjectDir();
const fetchMock = stagedFetch(
+2 -1
View File
@@ -15,6 +15,7 @@ import ignore, { type Ignore } from "ignore";
import { CSS_URL_RE, isNonRelativeUrl, isPathInside, rewriteSrcsetUrls } from "@hyperframes/core";
import { decodeUrlPathVariants, encodeUrlPath } from "@hyperframes/parsers";
import { buildAuthHeaders } from "../auth/client.js";
import { oauthEnvironment } from "../auth/config.js";
import { tryResolveCredential } from "../auth/index.js";
import { isAuthError } from "../auth/errors.js";
import { envCredentialVar, isTokenExpired, type ResolvedCredential } from "../auth/resolver.js";
@@ -703,7 +704,7 @@ export function getPublishApiBaseUrl(): string {
return (
process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"] ||
process.env["HEYGEN_API_URL"] ||
"https://api2.heygen.com"
`https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com`
).replace(/\/$/, "");
}
+1 -1
View File
@@ -18,7 +18,7 @@
"files": 13
},
"media-use": {
"hash": "938c00af915a46b5",
"hash": "d6a12782a304ff15",
"files": 116
},
"remotion-to-hyperframes": {
@@ -90,7 +90,7 @@ fs.writeFileSync(path.join(args[args.indexOf('--dir')+1], 'transcript.json'), JS
encoding: "utf8",
env: {
...process.env,
HEYGEN_CONFIG_DIR: config,
HYPERFRAMES_CONFIG_DIR: config,
HEYGEN_API_KEY: "",
HYPERFRAMES_API_KEY: "",
GEMINI_API_KEY: "fixture-key",
@@ -124,7 +124,7 @@ test("CLI partial run retains unselected voices, BGM and SFX", (t) => {
encoding: "utf8",
env: {
...process.env,
HEYGEN_CONFIG_DIR: dir,
HYPERFRAMES_CONFIG_DIR: dir,
HEYGEN_API_KEY: "",
HYPERFRAMES_API_KEY: "",
},
+23 -7
View File
@@ -3,8 +3,8 @@ 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 → ~/.heygen/
// credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR
// $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.
// Vendored so the skill ships standalone. Pure node.
@@ -20,7 +20,8 @@ export const HEYGEN_BASE = "https://api.heygen.com/v3";
// in the clear, so it needs $HEYGEN_ALLOW_HTTP=1, the heygen CLI's own rule.
export function heygenBase() {
const host = process.env.HEYGEN_API_BASE?.trim().replace(/\/+$/, "");
if (!host) return HEYGEN_BASE;
if (!host)
return process.env.HYPERFRAMES_ENV === "dev" ? "https://api.dev.heygen.com/v3" : HEYGEN_BASE;
if (host.startsWith("http://") && process.env.HEYGEN_ALLOW_HTTP !== "1")
throw new Error(
`HEYGEN_API_BASE (${host}) uses HTTP, which sends the key in plaintext. Set HEYGEN_ALLOW_HTTP=1 to allow it.`,
@@ -104,7 +105,7 @@ export function heygenCredential() {
}
// heygenCredential's answer, or { unreadable: { file, code } } when the credentials path exists but cannot be read
// (a folder, a locked ~/.heygen), so heygenAuthHeaders can say to fix that path: logging in again would fail there too.
// (a folder, a locked ~/.hyperframes), so heygenAuthHeaders can say to fix that path: logging in again would fail there too.
// Read without checking first, so the file cannot change between a check and the read.
function resolveCredential() {
const gatewayKey = hostGatewayKey();
@@ -116,7 +117,8 @@ function resolveCredential() {
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
if (envKey) return { headers: { "X-Api-Key": envKey } };
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
const root = process.env.HYPERFRAMES_CONFIG_DIR || join(homedir(), ".hyperframes");
const file = join(root, ...(process.env.HYPERFRAMES_ENV === "dev" ? ["dev"] : []), "credentials");
let raw;
try {
raw = readFileSync(file, "utf8").trim();
@@ -136,9 +138,23 @@ function resolveCredential() {
}
const oauth = cred.oauth;
if (oauth?.access_token) {
const expectedClient =
process.env.HYPERFRAMES_OAUTH_CLIENT_ID ||
(process.env.HYPERFRAMES_ENV === "dev"
? "9QoaiwCZov9O0cAkC4OZbR4p"
: "Zg3LF4FCLoeJoGs2YqMtZ17g");
const expectedTokenEndpoint =
process.env.HYPERFRAMES_OAUTH_TOKEN_URL ||
`https://api2.${process.env.HYPERFRAMES_ENV === "dev" ? "dev." : ""}heygen.com/v1/oauth/token`;
if (
!expectedClient ||
oauth.client_id !== expectedClient ||
oauth.token_endpoint !== expectedTokenEndpoint
)
return { expired: true };
const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now();
if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } };
if (!cred.api_key) return { expired: true };
return { expired: true };
}
if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } };
return null;
@@ -176,7 +192,7 @@ export function heygenAuthHeaders() {
"HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
);
throw new Error(
"no HeyGen credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.heygen/credentials)",
"no HyperFrames credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.hyperframes/credentials)",
);
}
@@ -17,7 +17,10 @@ const HEYGEN_ENV = [
"HEYGEN_ACCESS_TOKEN",
"HEYGEN_API_KEY",
"HYPERFRAMES_API_KEY",
"HEYGEN_CONFIG_DIR",
"HYPERFRAMES_CONFIG_DIR",
"HYPERFRAMES_ENV",
"HYPERFRAMES_OAUTH_CLIENT_ID",
"HYPERFRAMES_OAUTH_TOKEN_URL",
"HEYGEN_API_BASE",
"HEYGEN_ALLOW_HTTP",
];
@@ -61,11 +64,13 @@ test("heygenAuthHeaders tags OAuth requests as CLI traffic and with the media-us
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
writeFileSync(
join(dir, "credentials"),
JSON.stringify({
oauth: {
client_id: "Zg3LF4FCLoeJoGs2YqMtZ17g",
token_endpoint: "https://api2.heygen.com/v1/oauth/token",
access_token: "at_test",
expires_at: "2099-01-01T00:00:00Z",
},
@@ -82,6 +87,31 @@ test("heygenAuthHeaders tags OAuth requests as CLI traffic and with the media-us
});
});
test("development audio uses the development client and isolated store", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "hf-dev-cred-"));
try {
process.env.HYPERFRAMES_CONFIG_DIR = dir;
process.env.HYPERFRAMES_ENV = "dev";
mkdirSync(join(dir, "dev"));
writeFileSync(
join(dir, "dev", "credentials"),
JSON.stringify({
oauth: {
access_token: "dev-at",
client_id: "9QoaiwCZov9O0cAkC4OZbR4p",
token_endpoint: "https://api2.dev.heygen.com/v1/oauth/token",
},
}),
);
assert.equal(heygenBase(), "https://api.dev.heygen.com/v3");
assert.equal(heygenAuthHeaders().Authorization, "Bearer dev-at");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("a host-injected HEYGEN_ACCESS_TOKEN wins over an API key and is treated as OAuth", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
@@ -106,11 +136,13 @@ test("heygenAuthMethod returns oauth for a live OAuth credential", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
writeFileSync(
join(dir, "credentials"),
JSON.stringify({
oauth: {
client_id: "Zg3LF4FCLoeJoGs2YqMtZ17g",
token_endpoint: "https://api2.heygen.com/v1/oauth/token",
access_token: "at_test",
expires_at: "2099-01-01T00:00:00Z",
},
@@ -127,7 +159,7 @@ test("heygenAuthMethod returns null with no credential at all", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir; // no credentials file written
process.env.HYPERFRAMES_CONFIG_DIR = dir; // no credentials file written
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -135,6 +167,32 @@ test("heygenAuthMethod returns null with no credential at all", () => {
});
});
test("an expired or foreign OAuth login never falls back to a saved API wallet", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "hf-auth-payer-"));
try {
process.env.HYPERFRAMES_CONFIG_DIR = dir;
const credential = {
api_key: "other-wallet",
oauth: {
client_id: "Zg3LF4FCLoeJoGs2YqMtZ17g",
token_endpoint: "https://api2.heygen.com/v1/oauth/token",
access_token: "expired",
expires_at: "2000-01-01T00:00:00Z",
},
};
writeFileSync(join(dir, "credentials"), JSON.stringify(credential));
assert.deepEqual(heygenCredential(), { expired: true });
credential.oauth.expires_at = "2099-01-01T00:00:00Z";
credential.oauth.client_id = "shared-heygen-client";
writeFileSync(join(dir, "credentials"), JSON.stringify(credential));
assert.deepEqual(heygenCredential(), { expired: true });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("loadEnvFromDir skips a .env folder and loads the .env file above it", () => {
const root = mkdtempSync(join(tmpdir(), "heygen-env-"));
const project = join(root, "project");
@@ -175,7 +233,7 @@ test("heygenAuthMethod returns null when the credentials path is a folder", () =
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
mkdirSync(join(dir, "credentials"));
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -188,7 +246,7 @@ test("heygenAuthMethod returns null when the credentials path is a symlink loop"
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
symlinkSync(join(dir, "credentials"), join(dir, "credentials"));
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -200,8 +258,8 @@ test("heygenAuthHeaders says to fix an unreadable credentials path, and to log i
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
assert.throws(() => heygenAuthHeaders(), /no HeyGen credentials/);
process.env.HYPERFRAMES_CONFIG_DIR = dir;
assert.throws(() => heygenAuthHeaders(), /no HyperFrames credentials/);
mkdirSync(join(dir, "credentials"));
assert.equal(heygenCredential(), null);
assert.throws(
@@ -276,12 +334,12 @@ test("a base that isn't HeyGen's gets no stored or host OAuth credential, only a
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
writeFileSync(join(dir, "credentials"), JSON.stringify({ api_key: "hg_stored" }));
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
process.env.HEYGEN_API_BASE = "https://proxy.example.com";
assert.equal(heygenCredential(), null);
assert.throws(() => heygenAuthHeaders(), /no HeyGen credentials/);
assert.throws(() => heygenAuthHeaders(), /no HyperFrames credentials/);
// HeyGen's own hosts keep every credential source.
process.env.HEYGEN_API_BASE = "https://api-canary.heygen.com";
assert.equal(heygenAuthMethod(), "oauth");
@@ -209,7 +209,7 @@ test(
);
test("pickProvider heygen with an unreadable credentials path says to fix that path", () => {
const saved = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"].map((k) => [
const saved = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HYPERFRAMES_CONFIG_DIR"].map((k) => [
k,
process.env[k],
]);
@@ -217,7 +217,7 @@ test("pickProvider heygen with an unreadable credentials path says to fix that p
try {
delete process.env.HEYGEN_API_KEY;
delete process.env.HYPERFRAMES_API_KEY;
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
mkdirSync(join(dir, "credentials"));
assert.throws(() => pickProvider("heygen"), /fix or remove that path/);
} finally {