mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 15:49:53 +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",
|
authorization: "Bearer at_123",
|
||||||
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
|
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
|
||||||
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
|
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
|
||||||
|
heygen_route: "canary",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ describe("auth/client", () => {
|
|||||||
expect(buildAuthHeaders(apiKeyCred())).toEqual({
|
expect(buildAuthHeaders(apiKeyCred())).toEqual({
|
||||||
"x-api-key": "hg_x",
|
"x-api-key": "hg_x",
|
||||||
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
|
[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 type { ResolvedCredential } from "./resolver.js";
|
||||||
import { scrubCredentials } from "./scrub.js";
|
import { scrubCredentials } from "./scrub.js";
|
||||||
import type { OAuthTokens } from "./store.js";
|
import type { OAuthTokens } from "./store.js";
|
||||||
|
import { withHeygenCanaryRoute } from "../utils/heygenRoute.js";
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = "https://api.heygen.com";
|
const DEFAULT_BASE_URL = "https://api.heygen.com";
|
||||||
export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source";
|
export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source";
|
||||||
@@ -185,17 +186,20 @@ export class AuthClient {
|
|||||||
|
|
||||||
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
|
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
|
||||||
if (credential.type === "oauth") {
|
if (credential.type === "oauth") {
|
||||||
return {
|
return withHeygenCanaryRoute({
|
||||||
authorization: `Bearer ${credential.access_token}`,
|
authorization: `Bearer ${credential.access_token}`,
|
||||||
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
|
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
|
||||||
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
|
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
// API-key traffic keeps the normal billing path; the backend ignores the
|
// 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 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
|
// "cli-source claim on an API-key request"). The tool-attribution header IS
|
||||||
// sent here — an API-key hyperframes call is still hyperframes usage.
|
// 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> {
|
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 () => {
|
it("posts grant_type=refresh_token and persists the response", async () => {
|
||||||
process.env["HEYGEN_API_URL"] = "https://api.test.example";
|
process.env["HEYGEN_API_URL"] = "https://api.test.example";
|
||||||
let capturedBody: string | undefined;
|
let capturedBody: string | undefined;
|
||||||
|
let capturedHeaders: HeadersInit | undefined;
|
||||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||||
capturedBody = init?.body as string;
|
capturedBody = init?.body as string;
|
||||||
|
capturedHeaders = init?.headers;
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
access_token: "new_at",
|
access_token: "new_at",
|
||||||
@@ -192,6 +194,7 @@ describe("auth/oauth", () => {
|
|||||||
expect(tokens.refresh_token).toBe("new_rt");
|
expect(tokens.refresh_token).toBe("new_rt");
|
||||||
expect(capturedBody).toContain("grant_type=refresh_token");
|
expect(capturedBody).toContain("grant_type=refresh_token");
|
||||||
expect(capturedBody).toContain("refresh_token=old_rt");
|
expect(capturedBody).toContain("refresh_token=old_rt");
|
||||||
|
expect(capturedHeaders).toMatchObject({ heygen_route: "canary" });
|
||||||
|
|
||||||
// Should have persisted.
|
// Should have persisted.
|
||||||
const { credentials } = await readStore();
|
const { credentials } = await readStore();
|
||||||
@@ -295,8 +298,10 @@ describe("auth/oauth", () => {
|
|||||||
|
|
||||||
it("sends token_type_hint when provided", async () => {
|
it("sends token_type_hint when provided", async () => {
|
||||||
let capturedBody = "";
|
let capturedBody = "";
|
||||||
|
let capturedHeaders: HeadersInit | undefined;
|
||||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||||
capturedBody = init?.body as string;
|
capturedBody = init?.body as string;
|
||||||
|
capturedHeaders = init?.headers;
|
||||||
return new Response("", { status: 200 });
|
return new Response("", { status: 200 });
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
await revokeTokens("tok", {
|
await revokeTokens("tok", {
|
||||||
@@ -304,6 +309,7 @@ describe("auth/oauth", () => {
|
|||||||
token_type_hint: "refresh_token",
|
token_type_hint: "refresh_token",
|
||||||
});
|
});
|
||||||
expect(capturedBody).toContain("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 () => {
|
it("returns silently when client_id is unconfigured (no throw)", async () => {
|
||||||
@@ -337,6 +343,21 @@ describe("auth/oauth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("startAuthorizationCodeFlow persistence", () => {
|
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 () => {
|
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
|
// Pre-seed a prior session whose refresh_token must NOT leak into
|
||||||
// the new login when the new response omits one.
|
// 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 () => {
|
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 = [
|
const responses = [
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -485,6 +510,7 @@ describe("auth/oauth", () => {
|
|||||||
requests.push({
|
requests.push({
|
||||||
url: String(url),
|
url: String(url),
|
||||||
body: new URLSearchParams(String(init?.body ?? "")),
|
body: new URLSearchParams(String(init?.body ?? "")),
|
||||||
|
headers: init?.headers,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const sleeps: number[] = [];
|
const sleeps: number[] = [];
|
||||||
@@ -514,6 +540,12 @@ describe("auth/oauth", () => {
|
|||||||
expect(requests[1]?.body.get("grant_type")).toBe(
|
expect(requests[1]?.body.get("grant_type")).toBe(
|
||||||
"urn:ietf:params:oauth:grant-type:device_code",
|
"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");
|
expect((await readStore()).source).toBe("absent");
|
||||||
|
|
||||||
await persistFreshOAuth(tokens);
|
await persistFreshOAuth(tokens);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
type StoredUserInfo,
|
type StoredUserInfo,
|
||||||
} from "./store.js";
|
} from "./store.js";
|
||||||
import { c } from "../ui/colors.js";
|
import { c } from "../ui/colors.js";
|
||||||
|
import { withHeygenCanaryRoute } from "../utils/heygenRoute.js";
|
||||||
|
|
||||||
const REVOKE_TIMEOUT_MS = 5_000;
|
const REVOKE_TIMEOUT_MS = 5_000;
|
||||||
const MIN_EXPIRES_IN_SECONDS = 30;
|
const MIN_EXPIRES_IN_SECONDS = 30;
|
||||||
@@ -255,10 +256,10 @@ async function requestDeviceAuthorization(
|
|||||||
async (signal) => {
|
async (signal) => {
|
||||||
const response = await runtime.fetchImpl(deviceAuthorizationEndpoint(), {
|
const response = await runtime.fetchImpl(deviceAuthorizationEndpoint(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: withHeygenCanaryRoute({
|
||||||
"content-type": "application/x-www-form-urlencoded",
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
},
|
}),
|
||||||
body: new URLSearchParams({ client_id: runtime.clientId, scope }).toString(),
|
body: new URLSearchParams({ client_id: runtime.clientId, scope }).toString(),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -303,10 +304,10 @@ async function requestDeviceToken(
|
|||||||
async (signal) => {
|
async (signal) => {
|
||||||
const response = await runtime.fetchImpl(tokenEndpoint(), {
|
const response = await runtime.fetchImpl(tokenEndpoint(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: withHeygenCanaryRoute({
|
||||||
"content-type": "application/x-www-form-urlencoded",
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
},
|
}),
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: DEVICE_CODE_GRANT_TYPE,
|
grant_type: DEVICE_CODE_GRANT_TYPE,
|
||||||
device_code: deviceCode,
|
device_code: deviceCode,
|
||||||
@@ -394,10 +395,10 @@ export async function refreshTokens(
|
|||||||
|
|
||||||
const res = await fetchImpl(tokenEndpoint(), {
|
const res = await fetchImpl(tokenEndpoint(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: withHeygenCanaryRoute({
|
||||||
"content-type": "application/x-www-form-urlencoded",
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
},
|
}),
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,7 +447,9 @@ export async function revokeTokens(token: string, opts: RevokeOptions = {}): Pro
|
|||||||
try {
|
try {
|
||||||
const res = await fetchImpl(revokeEndpoint(), {
|
const res = await fetchImpl(revokeEndpoint(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
headers: withHeygenCanaryRoute({
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
}),
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
@@ -507,10 +510,10 @@ async function exchangeCodeForTokens(args: {
|
|||||||
});
|
});
|
||||||
const res = await fetchImpl(tokenEndpoint(), {
|
const res = await fetchImpl(tokenEndpoint(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: withHeygenCanaryRoute({
|
||||||
"content-type": "application/x-www-form-urlencoded",
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
},
|
}),
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
});
|
||||||
if (res.status === 400 || res.status === 401) {
|
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 ignore, { type Ignore } from "ignore";
|
||||||
import { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "@hyperframes/core";
|
import { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "@hyperframes/core";
|
||||||
import { buildAuthHeaders } from "../auth/client.js";
|
import { buildAuthHeaders } from "../auth/client.js";
|
||||||
|
import { withHeygenCanaryRoute } from "./heygenRoute.js";
|
||||||
import { tryResolveCredential } from "../auth/index.js";
|
import { tryResolveCredential } from "../auth/index.js";
|
||||||
import { writeProjectLink } from "./projectLink.js";
|
import { writeProjectLink } from "./projectLink.js";
|
||||||
|
|
||||||
@@ -557,10 +558,7 @@ async function publishProjectArchiveDirect(
|
|||||||
"file",
|
"file",
|
||||||
new File([archiveArrayBuffer(archive)], `${title}.zip`, { type: PUBLISH_CONTENT_TYPE }),
|
new File([archiveArrayBuffer(archive)], `${title}.zip`, { type: PUBLISH_CONTENT_TYPE }),
|
||||||
);
|
);
|
||||||
const headers: Record<string, string> = {
|
const headers = withHeygenCanaryRoute(authHeaders);
|
||||||
...authHeaders,
|
|
||||||
heygen_route: "canary",
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetchForPublish(
|
const response = await fetchForPublish(
|
||||||
`${apiBaseUrl}/v1/hyperframes/projects/publish`,
|
`${apiBaseUrl}/v1/hyperframes/projects/publish`,
|
||||||
@@ -623,11 +621,10 @@ async function publishProjectArchiveStaged(
|
|||||||
content_type: PUBLISH_CONTENT_TYPE,
|
content_type: PUBLISH_CONTENT_TYPE,
|
||||||
content_length: archive.buffer.byteLength,
|
content_length: archive.buffer.byteLength,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: withHeygenCanaryRoute({
|
||||||
...authHeaders,
|
...authHeaders,
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
heygen_route: "canary",
|
}),
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(PUBLISH_METADATA_TIMEOUT_MS),
|
signal: AbortSignal.timeout(PUBLISH_METADATA_TIMEOUT_MS),
|
||||||
}),
|
}),
|
||||||
"Failed to prepare project upload",
|
"Failed to prepare project upload",
|
||||||
@@ -657,11 +654,10 @@ async function publishProjectArchiveStaged(
|
|||||||
...(isPublic ? { is_public: true } : {}),
|
...(isPublic ? { is_public: true } : {}),
|
||||||
...(projectId ? { project_id: projectId } : {}),
|
...(projectId ? { project_id: projectId } : {}),
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: withHeygenCanaryRoute({
|
||||||
...authHeaders,
|
...authHeaders,
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
heygen_route: "canary",
|
}),
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(uploadTimeoutMs(archive.buffer.byteLength)),
|
signal: AbortSignal.timeout(uploadTimeoutMs(archive.buffer.byteLength)),
|
||||||
}),
|
}),
|
||||||
"Failed to finalize project publish",
|
"Failed to finalize project publish",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getPublishApiBaseUrl } from "./publishProject.js";
|
import { getPublishApiBaseUrl } from "./publishProject.js";
|
||||||
import { FEEDBACK_RATING_SCALE } from "./feedbackRating.js";
|
import { FEEDBACK_RATING_SCALE } from "./feedbackRating.js";
|
||||||
|
import { withHeygenCanaryRoute } from "./heygenRoute.js";
|
||||||
|
|
||||||
// Match the backend DTO caps (HyperframesFeedbackRequest). Truncate here so an
|
// Match the backend DTO caps (HyperframesFeedbackRequest). Truncate here so an
|
||||||
// over-long field (e.g. a pasted stack trace) is still forwarded truncated,
|
// 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),
|
cli_version: cap(input.cliVersion, MAX_CLI_VERSION),
|
||||||
env: cap(input.env, MAX_ENV),
|
env: cap(input.env, MAX_ENV),
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: withHeygenCanaryRoute({
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
heygen_route: "canary",
|
}),
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user