chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Minimal typed HTTP client for HeyGen endpoints needed by the auth
|
||||
* commands. Hand-written rather than codegen'd because the surface is
|
||||
* one endpoint (`/v3/users/me`) and pulling in an OpenAPI pipeline is
|
||||
* disproportionate.
|
||||
*
|
||||
* Reads `HEYGEN_API_URL` (default `https://api.heygen.com`) so dev
|
||||
* testing is one env var away.
|
||||
*
|
||||
* Auth header selection:
|
||||
* - OAuth → `Authorization: Bearer <token>`
|
||||
* - API key → `x-api-key: <key>`
|
||||
*
|
||||
* The backend `/v3/users/me` accepts both. See
|
||||
* `movio/api_service/app/controller/user_v3.py`.
|
||||
*/
|
||||
|
||||
import { ErrApi, ErrUnauthenticated, isAuthError } from "./errors.js";
|
||||
import type { ResolvedCredential } from "./resolver.js";
|
||||
import { scrubCredentials } from "./scrub.js";
|
||||
import type { OAuthTokens } from "./store.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.heygen.com";
|
||||
export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source";
|
||||
export const HEYGEN_CLI_SOURCE = "cli";
|
||||
|
||||
export function apiBaseUrl(): string {
|
||||
const override = process.env["HEYGEN_API_URL"];
|
||||
return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
export type BillingType = "wallet" | "subscription" | "usage_based" | string;
|
||||
|
||||
export interface WalletInfo {
|
||||
currency?: string;
|
||||
remaining_balance?: number;
|
||||
auto_reload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API returns `credits.{premium,add_on}_credits` as nested objects
|
||||
* (`{ remaining, resets_at? }`), not bare numbers — discovered live
|
||||
* against api.heygen.com. Modelling them as nested objects so the row
|
||||
* formatter can render them properly instead of `[object Object]`.
|
||||
*/
|
||||
export interface CreditBalance {
|
||||
remaining?: number;
|
||||
resets_at?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan?: string;
|
||||
credits?: {
|
||||
premium_credits?: CreditBalance;
|
||||
add_on_credits?: CreditBalance;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageBasedInfo {
|
||||
spending_current_usd?: number;
|
||||
spending_cap_usd?: number;
|
||||
}
|
||||
|
||||
/** Subset of the backend response we surface to users today. */
|
||||
export interface UserInfo {
|
||||
username?: string;
|
||||
email?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
billing_type?: BillingType;
|
||||
wallet?: WalletInfo;
|
||||
subscription?: SubscriptionInfo;
|
||||
usage_based?: UsageBasedInfo;
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
/** Override base URL (otherwise `HEYGEN_API_URL` / default). */
|
||||
baseUrl?: string;
|
||||
/** Inject a custom fetch (used by tests). */
|
||||
fetchImpl?: typeof fetch;
|
||||
/**
|
||||
* Hook for refreshing an OAuth credential on 401. The hook should
|
||||
* exchange the supplied refresh_token for new tokens, persist them,
|
||||
* and return the FULL new token set — at minimum a fresh
|
||||
* `access_token`, plus a fresh `refresh_token` if the IdP rotated it.
|
||||
* Returning the full set lets the retry's credential carry the new
|
||||
* refresh_token, so any subsequent refresh on the same in-memory
|
||||
* credential doesn't re-use a now-invalidated rotated RT.
|
||||
* Wired in by the auth commands; injectable for tests.
|
||||
*/
|
||||
onUnauthenticatedRefresh?: (refresh_token: string) => Promise<OAuthTokens>;
|
||||
}
|
||||
|
||||
export class AuthClient {
|
||||
private readonly base: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly onRefresh?: (refresh_token: string) => Promise<OAuthTokens>;
|
||||
|
||||
constructor(opts: AuthClientOptions = {}) {
|
||||
this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, "");
|
||||
this.fetchImpl = opts.fetchImpl ?? fetch;
|
||||
this.onRefresh = opts.onUnauthenticatedRefresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi`
|
||||
* on any other non-2xx or non-JSON body.
|
||||
*
|
||||
* On OAuth 401 with a refresh hook configured, the request is
|
||||
* retried once after refreshing the access token. The retry's
|
||||
* outcome is what the caller sees — if the refresh itself fails
|
||||
* (REFRESH_FAILED) or the retry still 401s, the user lands on a
|
||||
* "please log in again" path upstream.
|
||||
*/
|
||||
async getCurrentUser(credential: ResolvedCredential): Promise<UserInfo> {
|
||||
const url = `${this.base}/v3/users/me`;
|
||||
return await this.fetchUser(url, credential, true);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
private async fetchUser(
|
||||
url: string,
|
||||
credential: ResolvedCredential,
|
||||
allowRefresh: boolean,
|
||||
): Promise<UserInfo> {
|
||||
const headers = buildAuthHeaders(credential);
|
||||
const res = await this.fetchImpl(url, { method: "GET", headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
if (
|
||||
allowRefresh &&
|
||||
credential.type === "oauth" &&
|
||||
credential.refresh_token &&
|
||||
this.onRefresh
|
||||
) {
|
||||
const refreshed = await this.tryRefresh(credential.refresh_token);
|
||||
if (refreshed) {
|
||||
// Carry the new refresh_token forward too — for IdPs that
|
||||
// rotate RTs on every refresh, a future retry on this
|
||||
// in-memory credential would otherwise re-send the old
|
||||
// (now-invalidated) one.
|
||||
const next: ResolvedCredential = {
|
||||
...credential,
|
||||
access_token: refreshed.access_token,
|
||||
...(refreshed.refresh_token ? { refresh_token: refreshed.refresh_token } : {}),
|
||||
};
|
||||
return await this.fetchUser(url, next, false);
|
||||
}
|
||||
}
|
||||
const detail = await safeText(res);
|
||||
throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch (err) {
|
||||
throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`);
|
||||
}
|
||||
return extractUserInfo(payload);
|
||||
}
|
||||
|
||||
private async tryRefresh(refresh_token: string): Promise<OAuthTokens | null> {
|
||||
if (!this.onRefresh) return null;
|
||||
try {
|
||||
return await this.onRefresh(refresh_token);
|
||||
} catch (err) {
|
||||
// Refresh failure should be surfaced upstream by the caller via
|
||||
// the retry's 401, not by throwing here — so callers consistently
|
||||
// see "please log in again" rather than mixed error types.
|
||||
if (isAuthError(err) && err.code === "REFRESH_FAILED") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
|
||||
if (credential.type === "oauth") {
|
||||
return {
|
||||
authorization: `Bearer ${credential.access_token}`,
|
||||
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
|
||||
};
|
||||
}
|
||||
// API-key traffic keeps the normal billing path; the backend ignores the
|
||||
// cli-source header for it, so we don't send it (avoids a contradictory
|
||||
// "cli-source claim on an API-key request").
|
||||
return { "x-api-key": credential.key };
|
||||
}
|
||||
|
||||
async function safeText(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await res.text()).slice(0, 500);
|
||||
return scrubCredentials(body);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend wraps responses in `{code, message, data: {...}}` for some
|
||||
* endpoints and returns raw fields directly for others. Handle both.
|
||||
*/
|
||||
function extractUserInfo(payload: unknown): UserInfo {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const wrapped = obj["data"];
|
||||
const data =
|
||||
wrapped && typeof wrapped === "object" && !Array.isArray(wrapped)
|
||||
? (wrapped as Record<string, unknown>)
|
||||
: obj;
|
||||
return {
|
||||
username: pickString(data, "username"),
|
||||
email: pickString(data, "email"),
|
||||
first_name: pickString(data, "first_name"),
|
||||
last_name: pickString(data, "last_name"),
|
||||
billing_type: pickString(data, "billing_type"),
|
||||
wallet: pickObject(data, "wallet") as WalletInfo | undefined,
|
||||
subscription: pickObject(data, "subscription") as SubscriptionInfo | undefined,
|
||||
usage_based: pickObject(data, "usage_based") as UsageBasedInfo | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function pickString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const v = obj[key];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
}
|
||||
|
||||
function pickObject(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const v = obj[key];
|
||||
return v && typeof v === "object" && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user