From f71b7dd694ea9f11ef2cd140d2cecd773a84fbe5 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 20 May 2026 21:53:32 -0300 Subject: [PATCH] feat(fetch): identify CLI in outbound HTTP with a Clerk-CLI User-Agent Sets `Clerk-CLI/ (Bun/; -[; ci])` on every outbound request via the central loggedFetch helper, replacing Bun's default `Bun/` 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. --- .changeset/user-agent-header.md | 5 +++ packages/cli-core/src/commands/api/catalog.ts | 8 ++-- packages/cli-core/src/lib/fetch.test.ts | 44 +++++++++++++++++++ packages/cli-core/src/lib/fetch.ts | 7 ++- .../cli-core/src/lib/token-exchange.test.ts | 6 +-- packages/cli-core/src/lib/user-agent.test.ts | 34 ++++++++++++++ packages/cli-core/src/lib/user-agent.ts | 20 +++++++++ 7 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 .changeset/user-agent-header.md create mode 100644 packages/cli-core/src/lib/fetch.test.ts create mode 100644 packages/cli-core/src/lib/user-agent.test.ts create mode 100644 packages/cli-core/src/lib/user-agent.ts diff --git a/.changeset/user-agent-header.md b/.changeset/user-agent-header.md new file mode 100644 index 00000000..c3ada09d --- /dev/null +++ b/.changeset/user-agent-header.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Identify the CLI in outbound HTTP requests with a `User-Agent` like `Clerk-CLI/ (Bun/; -)` instead of the default Bun user agent. Allow callers to override the header. diff --git a/packages/cli-core/src/commands/api/catalog.ts b/packages/cli-core/src/commands/api/catalog.ts index 44aa3234..bdb5ee93 100644 --- a/packages/cli-core/src/commands/api/catalog.ts +++ b/packages/cli-core/src/commands/api/catalog.ts @@ -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"; @@ -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}`); } diff --git a/packages/cli-core/src/lib/fetch.test.ts b/packages/cli-core/src/lib/fetch.test.ts new file mode 100644 index 00000000..39f03188 --- /dev/null +++ b/packages/cli-core/src/lib/fetch.test.ts @@ -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).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).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).mock.calls[0]!; + expect(init.headers.get("Authorization")).toBe("Bearer abc"); + expect(init.headers.get("User-Agent")).toMatch(/^Clerk-CLI\//); + }); +}); diff --git a/packages/cli-core/src/lib/fetch.ts b/packages/cli-core/src/lib/fetch.ts index 4de8de20..1c5a8803 100644 --- a/packages/cli-core/src/lib/fetch.ts +++ b/packages/cli-core/src/lib/fetch.ts @@ -10,6 +10,9 @@ 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 }; @@ -17,10 +20,12 @@ export async function loggedFetch(url: URL | string, options: LoggedFetchInit): 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. diff --git a/packages/cli-core/src/lib/token-exchange.test.ts b/packages/cli-core/src/lib/token-exchange.test.ts index dd3c0264..179a90ff 100644 --- a/packages/cli-core/src/lib/token-exchange.test.ts +++ b/packages/cli-core/src/lib/token-exchange.test.ts @@ -34,7 +34,7 @@ describe("exchangeCodeForToken", () => { const [, calledInit] = (globalThis.fetch as unknown as ReturnType).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"); @@ -118,7 +118,7 @@ describe("fetchUserInfo", () => { await fetchUserInfo("my-secret-token"); const [, init] = (globalThis.fetch as unknown as ReturnType).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 () => { @@ -165,7 +165,7 @@ describe("refreshAccessToken", () => { const [, calledInit] = (globalThis.fetch as unknown as ReturnType).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"); diff --git a/packages/cli-core/src/lib/user-agent.test.ts b/packages/cli-core/src/lib/user-agent.test.ts new file mode 100644 index 00000000..98d038f0 --- /dev/null +++ b/packages/cli-core/src/lib/user-agent.test.ts @@ -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/", () => { + expect(buildUserAgent()).toMatch(/^Clerk-CLI\/\S+ /); + }); + + test("includes Bun/ 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]+$/); + }); +}); diff --git a/packages/cli-core/src/lib/user-agent.ts b/packages/cli-core/src/lib/user-agent.ts new file mode 100644 index 00000000..5b136fc2 --- /dev/null +++ b/packages/cli-core/src/lib/user-agent.ts @@ -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/`, which is + * indistinguishable from any other Bun-based client. + * + * Format: `Clerk-CLI/ (Bun/; -[; ci])` + * - : darwin | linux | win32 | … (process.platform) + * - : 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; + const segments = [`Bun/${Bun.version}`, `${process.platform}-${process.arch}`]; + if (process.env.CI) segments.push("ci"); + return `Clerk-CLI/${version} (${segments.join("; ")})`; +}