Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/auth/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,15 @@ describe("auth/client", () => {
authorization: "Bearer at_123",
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
heygen_route: "canary",
});
});

it("buildAuthHeaders uses x-api-key for api_key, without the cli-source header but with the tool tag", () => {
expect(buildAuthHeaders(apiKeyCred())).toEqual({
"x-api-key": "hg_x",
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
heygen_route: "canary",
});
});

Expand Down
10 changes: 7 additions & 3 deletions packages/cli/src/auth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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> {
Expand Down
34 changes: 33 additions & 1 deletion packages/cli/src/auth/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();
Expand Down Expand Up @@ -295,15 +298,18 @@ 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", {
fetchImpl,
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 () => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -485,6 +510,7 @@ describe("auth/oauth", () => {
requests.push({
url: String(url),
body: new URLSearchParams(String(init?.body ?? "")),
headers: init?.headers,
});
});
const sleeps: number[] = [];
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 12 additions & 9 deletions packages/cli/src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
});

Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/utils/heygenRoute.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
16 changes: 6 additions & 10 deletions packages/cli/src/utils/publishProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/utils/submitFeedback.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading