From d6a8ed5eeac654e15a20fd91cedfa9c10df1dabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 11 Aug 2026 01:24:51 +0000 Subject: [PATCH] fix(cli): route HeyGen API calls through canary --- packages/cli/src/auth/client.test.ts | 2 ++ packages/cli/src/auth/client.ts | 10 ++++--- packages/cli/src/auth/oauth.test.ts | 34 +++++++++++++++++++++++- packages/cli/src/auth/oauth.ts | 21 ++++++++------- packages/cli/src/utils/heygenRoute.ts | 9 +++++++ packages/cli/src/utils/publishProject.ts | 16 +++++------ packages/cli/src/utils/submitFeedback.ts | 6 ++--- 7 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 packages/cli/src/utils/heygenRoute.ts diff --git a/packages/cli/src/auth/client.test.ts b/packages/cli/src/auth/client.test.ts index 844502d77e..a2343a32f3 100644 --- a/packages/cli/src/auth/client.test.ts +++ b/packages/cli/src/auth/client.test.ts @@ -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", }); }); diff --git a/packages/cli/src/auth/client.ts b/packages/cli/src/auth/client.ts index 0bb3f92bb4..659e64e00f 100644 --- a/packages/cli/src/auth/client.ts +++ b/packages/cli/src/auth/client.ts @@ -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 { 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 { diff --git a/packages/cli/src/auth/oauth.test.ts b/packages/cli/src/auth/oauth.test.ts index fcc9ac5150..2c2258f6ae 100644 --- a/packages/cli/src/auth/oauth.test.ts +++ b/packages/cli/src/auth/oauth.test.ts @@ -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); diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index a1c0a20476..1c8a2d99a6 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -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) { diff --git a/packages/cli/src/utils/heygenRoute.ts b/packages/cli/src/utils/heygenRoute.ts new file mode 100644 index 0000000000..0654fab210 --- /dev/null +++ b/packages/cli/src/utils/heygenRoute.ts @@ -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 = {}, +): Record { + return { ...headers, [HEYGEN_ROUTE_HEADER]: HEYGEN_CANARY_ROUTE }; +} diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index 89c2585c80..ab4411b260 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -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 = { - ...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", diff --git a/packages/cli/src/utils/submitFeedback.ts b/packages/cli/src/utils/submitFeedback.ts index 3c239ad524..219a01574d 100644 --- a/packages/cli/src/utils/submitFeedback.ts +++ b/packages/cli/src/utils/submitFeedback.ts @@ -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 {