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
5 changes: 5 additions & 0 deletions .changeset/user-agent-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Identify the CLI in outbound HTTP requests with a `User-Agent` like `Clerk-CLI/<version> (Bun/<bun-version>; <platform>-<arch>)` instead of the default Bun user agent. Allow callers to override the header.
8 changes: 3 additions & 5 deletions packages/cli-core/src/commands/api/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { CLERK_CACHE_DIR, CACHE_TTL_MS, OPENAPI_SPEC_URLS } from "../../lib/constants.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { withHomeFsAccess, withNetworkAccess } from "../../lib/host-execution.ts";
import { loggedFetch } from "../../lib/fetch.ts";
import { withHomeFsAccess } from "../../lib/host-execution.ts";
import { withSpinner } from "../../lib/spinner.ts";
import { log } from "../../lib/log.ts";

Expand Down Expand Up @@ -141,10 +142,7 @@ export async function loadCatalog(options: { platform?: boolean } = {}): Promise
const url = platform ? OPENAPI_SPEC_URLS.platform : OPENAPI_SPEC_URLS.bapi;
try {
const catalog = await withSpinner("Fetching API catalog...", async () => {
const response = await withNetworkAccess(
{ operation: "connect", target: url, label: "api-catalog" },
async () => fetch(url),
);
const response = await loggedFetch(url, { tag: "api-catalog" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
Expand Down
44 changes: 44 additions & 0 deletions packages/cli-core/src/lib/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { test, expect, describe, afterEach, mock } from "bun:test";
import { loggedFetch } from "./fetch.ts";

const originalFetch = globalThis.fetch;

describe("loggedFetch", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});

test("sets a Clerk-CLI User-Agent on outbound requests", async () => {
globalThis.fetch = mock(
async () => new Response("ok", { status: 200 }),
) as unknown as typeof fetch;
await loggedFetch("https://example.test/x", { tag: "test" });
const [, init] = (globalThis.fetch as unknown as ReturnType<typeof mock>).mock.calls[0]!;
expect(init.headers.get("User-Agent")).toMatch(/^Clerk-CLI\//);
});

test("preserves a caller-provided User-Agent", async () => {
globalThis.fetch = mock(
async () => new Response("ok", { status: 200 }),
) as unknown as typeof fetch;
await loggedFetch("https://example.test/x", {
tag: "test",
headers: { "User-Agent": "Custom/1.0" },
});
const [, init] = (globalThis.fetch as unknown as ReturnType<typeof mock>).mock.calls[0]!;
expect(init.headers.get("User-Agent")).toBe("Custom/1.0");
});

test("preserves other caller-provided headers", async () => {
globalThis.fetch = mock(
async () => new Response("ok", { status: 200 }),
) as unknown as typeof fetch;
await loggedFetch("https://example.test/x", {
tag: "test",
headers: { Authorization: "Bearer abc" },
});
const [, init] = (globalThis.fetch as unknown as ReturnType<typeof mock>).mock.calls[0]!;
expect(init.headers.get("Authorization")).toBe("Bearer abc");
expect(init.headers.get("User-Agent")).toMatch(/^Clerk-CLI\//);
});
});
7 changes: 6 additions & 1 deletion packages/cli-core/src/lib/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,22 @@

import { log } from "./log.ts";
import { withNetworkAccess } from "./host-execution.ts";
import { buildUserAgent } from "./user-agent.ts";

const USER_AGENT = buildUserAgent();

export type LoggedFetchInit = RequestInit & { tag: string };

export async function loggedFetch(url: URL | string, options: LoggedFetchInit): Promise<Response> {
const { tag, ...init } = options;
const method = init.method ?? "GET";
const urlStr = url.toString();
const headers = new Headers(init.headers);
if (!headers.has("user-agent")) headers.set("User-Agent", USER_AGENT);
log.debug(`${tag}: ${method} ${urlStr}`);
const response = await withNetworkAccess(
{ operation: "connect", target: urlStr, label: tag },
async () => fetch(url, init),
async () => fetch(url, { ...init, headers }),
);
if (!response.ok) {
// Clone so the caller can still consume the body for error construction.
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/lib/token-exchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("exchangeCodeForToken", () => {

const [, calledInit] = (globalThis.fetch as unknown as ReturnType<typeof mock>).mock.calls[0]!;
expect(calledInit.method).toBe("POST");
expect(calledInit.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
expect(calledInit.headers.get("Content-Type")).toBe("application/x-www-form-urlencoded");

const body = new URLSearchParams(calledInit.body);
expect(body.get("grant_type")).toBe("authorization_code");
Expand Down Expand Up @@ -118,7 +118,7 @@ describe("fetchUserInfo", () => {
await fetchUserInfo("my-secret-token");

const [, init] = (globalThis.fetch as unknown as ReturnType<typeof mock>).mock.calls[0]!;
expect(init.headers.Authorization).toBe("Bearer my-secret-token");
expect(init.headers.get("Authorization")).toBe("Bearer my-secret-token");
});

test("throws on non-OK response with status code", async () => {
Expand Down Expand Up @@ -165,7 +165,7 @@ describe("refreshAccessToken", () => {

const [, calledInit] = (globalThis.fetch as unknown as ReturnType<typeof mock>).mock.calls[0]!;
expect(calledInit.method).toBe("POST");
expect(calledInit.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
expect(calledInit.headers.get("Content-Type")).toBe("application/x-www-form-urlencoded");

const body = new URLSearchParams(calledInit.body);
expect(body.get("grant_type")).toBe("refresh_token");
Expand Down
34 changes: 34 additions & 0 deletions packages/cli-core/src/lib/user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect, describe, afterEach } from "bun:test";
import { buildUserAgent } from "./user-agent.ts";

describe("buildUserAgent", () => {
const originalCi = process.env.CI;
afterEach(() => {
if (originalCi === undefined) delete process.env.CI;
else process.env.CI = originalCi;
});

test("starts with Clerk-CLI/<version>", () => {
expect(buildUserAgent()).toMatch(/^Clerk-CLI\/\S+ /);
});

test("includes Bun/<bun-version> and platform-arch", () => {
const ua = buildUserAgent();
expect(ua).toContain(`Bun/${Bun.version}`);
expect(ua).toContain(`${process.platform}-${process.arch}`);
});

test("appends ci segment when CI env is set", () => {
process.env.CI = "1";
expect(buildUserAgent()).toMatch(/; ci\)$/);
});

test("omits ci segment when CI env is unset", () => {
delete process.env.CI;
expect(buildUserAgent()).not.toMatch(/; ci\)/);
});

test("uses only printable ASCII characters", () => {
expect(buildUserAgent()).toMatch(/^[\x20-\x7e]+$/);
});
});
20 changes: 20 additions & 0 deletions packages/cli-core/src/lib/user-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Identifies the CLI in outbound HTTP calls so Clerk's edge can route or filter
* CLI traffic separately (e.g. to dedicated Cloud Run services). Without this
* we fall through to Bun's default `User-Agent: Bun/<version>`, which is
* indistinguishable from any other Bun-based client.
*
* Format: `Clerk-CLI/<version> (Bun/<bun-version>; <platform>-<arch>[; ci])`
* - <platform>: darwin | linux | win32 | … (process.platform)
* - <arch>: arm64 | x64 | … (process.arch)
* - `ci` segment is appended when running under a recognized CI environment.
*/

import { DEV_CLI_VERSION, resolveCliVersion } from "./version.ts";

export function buildUserAgent(): string {
const version = resolveCliVersion() ?? DEV_CLI_VERSION;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future it would be nice to encapsulate this as like getCliVersion() so callers don't need to worry about the fallback.

const segments = [`Bun/${Bun.version}`, `${process.platform}-${process.arch}`];
if (process.env.CI) segments.push("ci");
return `Clerk-CLI/${version} (${segments.join("; ")})`;
}