fix(cli): route HeyGen API calls through canary (#3201)

This commit is contained in:
Miguel Ángel
2026-08-10 21:26:46 -04:00
committed by GitHub
parent ce18acf075
commit 5545521556
7 changed files with 72 additions and 26 deletions
+2
View File
@@ -84,6 +84,7 @@ describe("auth/client", () => {
authorization: "Bearer at_123",
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
heygen_route: "canary",
});
});
@@ -91,6 +92,7 @@ describe("auth/client", () => {
expect(buildAuthHeaders(apiKeyCred())).toEqual({
"x-api-key": "hg_x",
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
heygen_route: "canary",
});
});
+7 -3
View File
@@ -19,6 +19,7 @@ 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 { withHeygenCanaryRoute } from "../utils/heygenRoute.js";
const DEFAULT_BASE_URL = "https://api.heygen.com";
export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source";
@@ -185,17 +186,20 @@ export class AuthClient {
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
if (credential.type === "oauth") {
return {
return withHeygenCanaryRoute({
authorization: `Bearer ${credential.access_token}`,
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_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"). The tool-attribution header IS
// sent here — an API-key hyperframes call is still hyperframes usage.
return { "x-api-key": credential.key, [HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE };
return withHeygenCanaryRoute({
"x-api-key": credential.key,
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
});
}
async function safeText(res: Response): Promise<string> {
+33 -1
View File
@@ -173,8 +173,10 @@ describe("auth/oauth", () => {
it("posts grant_type=refresh_token and persists the response", async () => {
process.env["HEYGEN_API_URL"] = "https://api.test.example";
let capturedBody: string | undefined;
let capturedHeaders: HeadersInit | undefined;
const fetchImpl = (async (_url: string, init?: RequestInit) => {
capturedBody = init?.body as string;
capturedHeaders = init?.headers;
return new Response(
JSON.stringify({
access_token: "new_at",
@@ -192,6 +194,7 @@ describe("auth/oauth", () => {
expect(tokens.refresh_token).toBe("new_rt");
expect(capturedBody).toContain("grant_type=refresh_token");
expect(capturedBody).toContain("refresh_token=old_rt");
expect(capturedHeaders).toMatchObject({ heygen_route: "canary" });
// Should have persisted.
const { credentials } = await readStore();
@@ -295,8 +298,10 @@ describe("auth/oauth", () => {
it("sends token_type_hint when provided", async () => {
let capturedBody = "";
let capturedHeaders: HeadersInit | undefined;
const fetchImpl = (async (_url: string, init?: RequestInit) => {
capturedBody = init?.body as string;
capturedHeaders = init?.headers;
return new Response("", { status: 200 });
}) as unknown as typeof fetch;
await revokeTokens("tok", {
@@ -304,6 +309,7 @@ describe("auth/oauth", () => {
token_type_hint: "refresh_token",
});
expect(capturedBody).toContain("token_type_hint=refresh_token");
expect(capturedHeaders).toMatchObject({ heygen_route: "canary" });
});
it("returns silently when client_id is unconfigured (no throw)", async () => {
@@ -337,6 +343,21 @@ describe("auth/oauth", () => {
});
describe("startAuthorizationCodeFlow persistence", () => {
it("routes the authorization-code exchange through canary", async () => {
let capturedHeaders: HeadersInit | undefined;
const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => {
capturedHeaders = init?.headers;
return new Response(JSON.stringify({ access_token: "new_at" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
await startAuthorizationCodeFlow({ fetchImpl });
expect(capturedHeaders).toMatchObject({ heygen_route: "canary" });
});
it("overwrites the OAuth block on fresh login (no inherited refresh_token)", async () => {
// Pre-seed a prior session whose refresh_token must NOT leak into
// the new login when the new response omits one.
@@ -450,7 +471,11 @@ describe("auth/oauth", () => {
});
it("polls pending and slow_down responses without persisting before identity verification", async () => {
const requests: Array<{ url: string; body: URLSearchParams }> = [];
const requests: Array<{
url: string;
body: URLSearchParams;
headers: HeadersInit | undefined;
}> = [];
const responses = [
new Response(
JSON.stringify({
@@ -485,6 +510,7 @@ describe("auth/oauth", () => {
requests.push({
url: String(url),
body: new URLSearchParams(String(init?.body ?? "")),
headers: init?.headers,
});
});
const sleeps: number[] = [];
@@ -514,6 +540,12 @@ describe("auth/oauth", () => {
expect(requests[1]?.body.get("grant_type")).toBe(
"urn:ietf:params:oauth:grant-type:device_code",
);
expect(requests.map(({ headers }) => headers)).toEqual([
expect.objectContaining({ heygen_route: "canary" }),
expect.objectContaining({ heygen_route: "canary" }),
expect.objectContaining({ heygen_route: "canary" }),
expect.objectContaining({ heygen_route: "canary" }),
]);
expect((await readStore()).source).toBe("absent");
await persistFreshOAuth(tokens);
+12 -9
View File
@@ -54,6 +54,7 @@ import {
type StoredUserInfo,
} from "./store.js";
import { c } from "../ui/colors.js";
import { withHeygenCanaryRoute } from "../utils/heygenRoute.js";
const REVOKE_TIMEOUT_MS = 5_000;
const MIN_EXPIRES_IN_SECONDS = 30;
@@ -255,10 +256,10 @@ async function requestDeviceAuthorization(
async (signal) => {
const response = await runtime.fetchImpl(deviceAuthorizationEndpoint(), {
method: "POST",
headers: {
headers: withHeygenCanaryRoute({
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
}),
body: new URLSearchParams({ client_id: runtime.clientId, scope }).toString(),
signal,
});
@@ -303,10 +304,10 @@ async function requestDeviceToken(
async (signal) => {
const response = await runtime.fetchImpl(tokenEndpoint(), {
method: "POST",
headers: {
headers: withHeygenCanaryRoute({
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
}),
body: new URLSearchParams({
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code: deviceCode,
@@ -394,10 +395,10 @@ export async function refreshTokens(
const res = await fetchImpl(tokenEndpoint(), {
method: "POST",
headers: {
headers: withHeygenCanaryRoute({
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
}),
body: body.toString(),
});
@@ -446,7 +447,9 @@ export async function revokeTokens(token: string, opts: RevokeOptions = {}): Pro
try {
const res = await fetchImpl(revokeEndpoint(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
headers: withHeygenCanaryRoute({
"content-type": "application/x-www-form-urlencoded",
}),
body: body.toString(),
signal: controller.signal,
});
@@ -507,10 +510,10 @@ async function exchangeCodeForTokens(args: {
});
const res = await fetchImpl(tokenEndpoint(), {
method: "POST",
headers: {
headers: withHeygenCanaryRoute({
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
}),
body: body.toString(),
});
if (res.status === 400 || res.status === 401) {
+9
View File
@@ -0,0 +1,9 @@
const HEYGEN_ROUTE_HEADER = "heygen_route";
const HEYGEN_CANARY_ROUTE = "canary";
/** Route CLI-owned HeyGen API calls through the EF canary deployment. */
export function withHeygenCanaryRoute(
headers: Record<string, string> = {},
): Record<string, string> {
return { ...headers, [HEYGEN_ROUTE_HEADER]: HEYGEN_CANARY_ROUTE };
}
+6 -10
View File
@@ -5,6 +5,7 @@ import AdmZip from "adm-zip";
import ignore, { type Ignore } from "ignore";
import { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "@hyperframes/core";
import { buildAuthHeaders } from "../auth/client.js";
import { withHeygenCanaryRoute } from "./heygenRoute.js";
import { tryResolveCredential } from "../auth/index.js";
import { writeProjectLink } from "./projectLink.js";
@@ -557,10 +558,7 @@ async function publishProjectArchiveDirect(
"file",
new File([archiveArrayBuffer(archive)], `${title}.zip`, { type: PUBLISH_CONTENT_TYPE }),
);
const headers: Record<string, string> = {
...authHeaders,
heygen_route: "canary",
};
const headers = withHeygenCanaryRoute(authHeaders);
const response = await fetchForPublish(
`${apiBaseUrl}/v1/hyperframes/projects/publish`,
@@ -623,11 +621,10 @@ async function publishProjectArchiveStaged(
content_type: PUBLISH_CONTENT_TYPE,
content_length: archive.buffer.byteLength,
}),
headers: {
headers: withHeygenCanaryRoute({
...authHeaders,
"content-type": "application/json",
heygen_route: "canary",
},
}),
signal: AbortSignal.timeout(PUBLISH_METADATA_TIMEOUT_MS),
}),
"Failed to prepare project upload",
@@ -657,11 +654,10 @@ async function publishProjectArchiveStaged(
...(isPublic ? { is_public: true } : {}),
...(projectId ? { project_id: projectId } : {}),
}),
headers: {
headers: withHeygenCanaryRoute({
...authHeaders,
"content-type": "application/json",
heygen_route: "canary",
},
}),
signal: AbortSignal.timeout(uploadTimeoutMs(archive.buffer.byteLength)),
}),
"Failed to finalize project publish",
+3 -3
View File
@@ -1,5 +1,6 @@
import { getPublishApiBaseUrl } from "./publishProject.js";
import { FEEDBACK_RATING_SCALE } from "./feedbackRating.js";
import { withHeygenCanaryRoute } from "./heygenRoute.js";
// Match the backend DTO caps (HyperframesFeedbackRequest). Truncate here so an
// over-long field (e.g. a pasted stack trace) is still forwarded truncated,
@@ -30,10 +31,9 @@ export async function submitFeedback(input: {
cli_version: cap(input.cliVersion, MAX_CLI_VERSION),
env: cap(input.env, MAX_ENV),
}),
headers: {
headers: withHeygenCanaryRoute({
"content-type": "application/json",
heygen_route: "canary",
},
}),
signal: AbortSignal.timeout(5000),
});
} catch {