Skip to content

Commit da2e3cf

Browse files
authored
feat(fetch): identify CLI in outbound HTTP with a Clerk-CLI User-Agent (#301)
Sets `Clerk-CLI/<version> (+repo-url; Bun/<v>; <platform>-<arch>[; ci])` on every outbound request via the central loggedFetch helper, replacing Bun's default `Bun/<v>` UA so Clerk's edge can route or filter CLI traffic separately (e.g. to dedicated PLAPI Cloud Run services). Also routes the lone direct fetch() in api/catalog through loggedFetch so it picks up the UA and gets the standard debug logging.
1 parent 2647f0d commit da2e3cf

7 files changed

Lines changed: 115 additions & 9 deletions

File tree

.changeset/user-agent-header.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": patch
3+
---
4+
5+
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.

packages/cli-core/src/commands/api/catalog.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { mkdir } from "node:fs/promises";
88
import { join } from "node:path";
99
import { CLERK_CACHE_DIR, CACHE_TTL_MS, OPENAPI_SPEC_URLS } from "../../lib/constants.ts";
1010
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
11-
import { withHomeFsAccess, withNetworkAccess } from "../../lib/host-execution.ts";
11+
import { loggedFetch } from "../../lib/fetch.ts";
12+
import { withHomeFsAccess } from "../../lib/host-execution.ts";
1213
import { withSpinner } from "../../lib/spinner.ts";
1314
import { log } from "../../lib/log.ts";
1415

@@ -141,10 +142,7 @@ export async function loadCatalog(options: { platform?: boolean } = {}): Promise
141142
const url = platform ? OPENAPI_SPEC_URLS.platform : OPENAPI_SPEC_URLS.bapi;
142143
try {
143144
const catalog = await withSpinner("Fetching API catalog...", async () => {
144-
const response = await withNetworkAccess(
145-
{ operation: "connect", target: url, label: "api-catalog" },
146-
async () => fetch(url),
147-
);
145+
const response = await loggedFetch(url, { tag: "api-catalog" });
148146
if (!response.ok) {
149147
throw new Error(`HTTP ${response.status}`);
150148
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { test, expect, describe, afterEach, mock } from "bun:test";
2+
import { loggedFetch } from "./fetch.ts";
3+
4+
const originalFetch = globalThis.fetch;
5+
6+
describe("loggedFetch", () => {
7+
afterEach(() => {
8+
globalThis.fetch = originalFetch;
9+
});
10+
11+
test("sets a Clerk-CLI User-Agent on outbound requests", async () => {
12+
globalThis.fetch = mock(
13+
async () => new Response("ok", { status: 200 }),
14+
) as unknown as typeof fetch;
15+
await loggedFetch("https://example.test/x", { tag: "test" });
16+
const [, init] = (globalThis.fetch as unknown as ReturnType<typeof mock>).mock.calls[0]!;
17+
expect(init.headers.get("User-Agent")).toMatch(/^Clerk-CLI\//);
18+
});
19+
20+
test("preserves a caller-provided User-Agent", async () => {
21+
globalThis.fetch = mock(
22+
async () => new Response("ok", { status: 200 }),
23+
) as unknown as typeof fetch;
24+
await loggedFetch("https://example.test/x", {
25+
tag: "test",
26+
headers: { "User-Agent": "Custom/1.0" },
27+
});
28+
const [, init] = (globalThis.fetch as unknown as ReturnType<typeof mock>).mock.calls[0]!;
29+
expect(init.headers.get("User-Agent")).toBe("Custom/1.0");
30+
});
31+
32+
test("preserves other caller-provided headers", async () => {
33+
globalThis.fetch = mock(
34+
async () => new Response("ok", { status: 200 }),
35+
) as unknown as typeof fetch;
36+
await loggedFetch("https://example.test/x", {
37+
tag: "test",
38+
headers: { Authorization: "Bearer abc" },
39+
});
40+
const [, init] = (globalThis.fetch as unknown as ReturnType<typeof mock>).mock.calls[0]!;
41+
expect(init.headers.get("Authorization")).toBe("Bearer abc");
42+
expect(init.headers.get("User-Agent")).toMatch(/^Clerk-CLI\//);
43+
});
44+
});

packages/cli-core/src/lib/fetch.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,22 @@
1010

1111
import { log } from "./log.ts";
1212
import { withNetworkAccess } from "./host-execution.ts";
13+
import { buildUserAgent } from "./user-agent.ts";
14+
15+
const USER_AGENT = buildUserAgent();
1316

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

1619
export async function loggedFetch(url: URL | string, options: LoggedFetchInit): Promise<Response> {
1720
const { tag, ...init } = options;
1821
const method = init.method ?? "GET";
1922
const urlStr = url.toString();
23+
const headers = new Headers(init.headers);
24+
if (!headers.has("user-agent")) headers.set("User-Agent", USER_AGENT);
2025
log.debug(`${tag}: ${method} ${urlStr}`);
2126
const response = await withNetworkAccess(
2227
{ operation: "connect", target: urlStr, label: tag },
23-
async () => fetch(url, init),
28+
async () => fetch(url, { ...init, headers }),
2429
);
2530
if (!response.ok) {
2631
// Clone so the caller can still consume the body for error construction.

packages/cli-core/src/lib/token-exchange.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe("exchangeCodeForToken", () => {
3434

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

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

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

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

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

170170
const body = new URLSearchParams(calledInit.body);
171171
expect(body.get("grant_type")).toBe("refresh_token");
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { test, expect, describe, afterEach } from "bun:test";
2+
import { buildUserAgent } from "./user-agent.ts";
3+
4+
describe("buildUserAgent", () => {
5+
const originalCi = process.env.CI;
6+
afterEach(() => {
7+
if (originalCi === undefined) delete process.env.CI;
8+
else process.env.CI = originalCi;
9+
});
10+
11+
test("starts with Clerk-CLI/<version>", () => {
12+
expect(buildUserAgent()).toMatch(/^Clerk-CLI\/\S+ /);
13+
});
14+
15+
test("includes Bun/<bun-version> and platform-arch", () => {
16+
const ua = buildUserAgent();
17+
expect(ua).toContain(`Bun/${Bun.version}`);
18+
expect(ua).toContain(`${process.platform}-${process.arch}`);
19+
});
20+
21+
test("appends ci segment when CI env is set", () => {
22+
process.env.CI = "1";
23+
expect(buildUserAgent()).toMatch(/; ci\)$/);
24+
});
25+
26+
test("omits ci segment when CI env is unset", () => {
27+
delete process.env.CI;
28+
expect(buildUserAgent()).not.toMatch(/; ci\)/);
29+
});
30+
31+
test("uses only printable ASCII characters", () => {
32+
expect(buildUserAgent()).toMatch(/^[\x20-\x7e]+$/);
33+
});
34+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Identifies the CLI in outbound HTTP calls so Clerk's edge can route or filter
3+
* CLI traffic separately (e.g. to dedicated Cloud Run services). Without this
4+
* we fall through to Bun's default `User-Agent: Bun/<version>`, which is
5+
* indistinguishable from any other Bun-based client.
6+
*
7+
* Format: `Clerk-CLI/<version> (Bun/<bun-version>; <platform>-<arch>[; ci])`
8+
* - <platform>: darwin | linux | win32 | … (process.platform)
9+
* - <arch>: arm64 | x64 | … (process.arch)
10+
* - `ci` segment is appended when running under a recognized CI environment.
11+
*/
12+
13+
import { DEV_CLI_VERSION, resolveCliVersion } from "./version.ts";
14+
15+
export function buildUserAgent(): string {
16+
const version = resolveCliVersion() ?? DEV_CLI_VERSION;
17+
const segments = [`Bun/${Bun.version}`, `${process.platform}-${process.arch}`];
18+
if (process.env.CI) segments.push("ci");
19+
return `Clerk-CLI/${version} (${segments.join("; ")})`;
20+
}

0 commit comments

Comments
 (0)