mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 07:40:06 +00:00
fix(cli): route HeyGen API calls through canary (#3201)
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user