mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(cli): use dedicated HyperFrames OAuth clients
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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"}',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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).
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(/\/$/, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user