diff --git a/.changeset/platform-api-url-credential-warning.md b/.changeset/platform-api-url-credential-warning.md new file mode 100644 index 00000000..a34a0185 --- /dev/null +++ b/.changeset/platform-api-url-credential-warning.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Warn (in human mode) when `CLERK_PLATFORM_API_URL` routes requests to a host that differs from the active environment's URL, since credentials are keyed by environment name and not by URL. `clerk doctor` now also reports the active environment and its API URL so the mismatch is visible. diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a8e7f113..85300be2 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -26,6 +26,7 @@ import { getCurrentEnvName, getAvailableEnvs, getPlapiBaseUrl, + isPlatformApiUrlOverridden, } from "./lib/environment.ts"; import { CliError, @@ -129,6 +130,17 @@ export function createProgram(): Program { if (activeEnv !== "production") { process.stderr.write(`[${activeEnv.toUpperCase()}]\n`); } + + // Warn when CLERK_PLATFORM_API_URL routes requests to a different host than + // the active environment's platform URL. Credentials are keyed by environment + // name, so the active env's token will be sent to the override host. Emitted + // to stderr so it never corrupts stdout data output. + const override = isPlatformApiUrlOverridden(); + if (override.overridden) { + log.warn( + `CLERK_PLATFORM_API_URL is routing requests to ${override.overrideUrl}, but credentials stay keyed to the "${override.envName}" environment — the "${override.envName}" token will be sent to that host.`, + ); + } }); // Show update notification after each command, except for commands that diff --git a/packages/cli-core/src/commands/doctor/checks.ts b/packages/cli-core/src/commands/doctor/checks.ts index b8bf9afa..53929eec 100644 --- a/packages/cli-core/src/commands/doctor/checks.ts +++ b/packages/cli-core/src/commands/doctor/checks.ts @@ -16,6 +16,7 @@ import { formatChannelLabel, } from "../../lib/update-check.ts"; import { formatHostStateProbeFailures, getAgentHostStateProbe } from "../../lib/host-execution.ts"; +import { getCurrentEnvName, getPlapiBaseUrl } from "../../lib/environment.ts"; import { isAgent } from "../../mode.ts"; import type { CheckResult, DoctorContext, FixAction } from "./types.ts"; @@ -74,7 +75,7 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise { remedy: "Run `clerk auth login` to authenticate.", }); } - return check.pass("Logged in (token found in credential store)"); + return check.pass(`Logged in — environment "${getCurrentEnvName()}", API ${getPlapiBaseUrl()}`); } export async function checkHostExecution(): Promise { diff --git a/packages/cli-core/src/lib/environment.test.ts b/packages/cli-core/src/lib/environment.test.ts new file mode 100644 index 00000000..827c51e1 --- /dev/null +++ b/packages/cli-core/src/lib/environment.test.ts @@ -0,0 +1,42 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { isPlatformApiUrlOverridden } from "./environment.ts"; + +describe("isPlatformApiUrlOverridden", () => { + const original = process.env.CLERK_PLATFORM_API_URL; + + beforeEach(() => { + delete process.env.CLERK_PLATFORM_API_URL; + }); + + afterEach(() => { + if (original === undefined) delete process.env.CLERK_PLATFORM_API_URL; + else process.env.CLERK_PLATFORM_API_URL = original; + }); + + test("returns overridden=true with URLs when the override differs from the active env URL", () => { + process.env.CLERK_PLATFORM_API_URL = "https://api.staging.example.com"; + const result = isPlatformApiUrlOverridden(); + expect(result.overridden).toBe(true); + if (!result.overridden) return; + expect(result.overrideUrl).toBe("https://api.staging.example.com"); + expect(result.profileUrl).toBe("https://api.clerk.com"); + expect(result.envName).toBe("production"); + }); + + test("returns overridden=false when no override is set", () => { + const result = isPlatformApiUrlOverridden(); + expect(result.overridden).toBe(false); + }); + + test("returns overridden=false when the override equals the active env URL", () => { + process.env.CLERK_PLATFORM_API_URL = "https://api.clerk.com"; + const result = isPlatformApiUrlOverridden(); + expect(result.overridden).toBe(false); + }); + + test("returns overridden=false when URLs differ only by trailing slash", () => { + process.env.CLERK_PLATFORM_API_URL = "https://api.clerk.com/"; + const result = isPlatformApiUrlOverridden(); + expect(result.overridden).toBe(false); + }); +}); diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 91643a28..c7fb9972 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -136,6 +136,42 @@ export function getPlapiBaseUrl(): string { return process.env.CLERK_PLATFORM_API_URL ?? getCurrentEnv().platformApiUrl; } +/** + * Returns whether CLERK_PLATFORM_API_URL is set to a URL that differs from the + * active environment's configured platform URL, along with both URLs so the + * caller can surface a warning. + * + * Comparison normalises both URLs via `new URL().href` so trailing-slash and + * case differences are ignored; falls back to raw string comparison if either + * value is not a valid URL. + */ +export function isPlatformApiUrlOverridden(): + | { + overridden: false; + } + | { + overridden: true; + overrideUrl: string; + profileUrl: string; + envName: string; + } { + const override = process.env.CLERK_PLATFORM_API_URL; + if (!override) return { overridden: false }; + + const profileUrl = getCurrentEnv().platformApiUrl; + const normalize = (u: string) => { + try { + return new URL(u).href; + } catch { + return u; + } + }; + + if (normalize(override) === normalize(profileUrl)) return { overridden: false }; + + return { overridden: true, overrideUrl: override, profileUrl, envName: getCurrentEnvName() }; +} + export function getBapiBaseUrl(): string { return process.env.CLERK_BACKEND_API_URL ?? getCurrentEnv().backendApiUrl; } diff --git a/packages/cli-core/src/test/integration/error-codes.test.ts b/packages/cli-core/src/test/integration/error-codes.test.ts index 06c81e83..311675df 100644 --- a/packages/cli-core/src/test/integration/error-codes.test.ts +++ b/packages/cli-core/src/test/integration/error-codes.test.ts @@ -9,7 +9,12 @@ import { useIntegrationTestHarness, clerk, mockState } from "./lib/harness.ts"; useIntegrationTestHarness(); function parseJsonError(stderr: string): { code: string; message: string; docsUrl?: string } { - const parsed = JSON.parse(stderr); + const jsonLine = stderr + .split("\n") + .map((l) => l.trim()) + .find((l) => l.startsWith("{")); + if (!jsonLine) throw new SyntaxError(`No JSON line found in stderr:\n${stderr}`); + const parsed = JSON.parse(jsonLine); return parsed.error; } diff --git a/packages/cli-core/src/test/integration/input-json.test.ts b/packages/cli-core/src/test/integration/input-json.test.ts index 0c23560b..13ef9238 100644 --- a/packages/cli-core/src/test/integration/input-json.test.ts +++ b/packages/cli-core/src/test/integration/input-json.test.ts @@ -18,6 +18,15 @@ useIntegrationTestHarness(); const devInstance = getInstance(MOCK_APP, "development"); +function parseJsonFromStderr(stderr: string): { error: { code?: string } } { + const jsonLine = stderr + .split("\n") + .map((l) => l.trim()) + .find((l) => l.startsWith("{")); + if (!jsonLine) throw new SyntaxError(`No JSON line found in stderr:\n${stderr}`); + return JSON.parse(jsonLine) as { error: { code?: string } }; +} + beforeEach(async () => { await setProfile("github.com/test/project", { workspaceId: "", @@ -40,7 +49,7 @@ test("explicit CLI flags override --input-json values", async () => { const result = await clerk.raw("init", "--input-json", '{"mode":"human"}', "--mode", "agent"); expect(result.exitCode).not.toBe(0); // Agent mode emits structured JSON to stderr; human mode emits plain text. - const parsed = JSON.parse(result.stderr); + const parsed = parseJsonFromStderr(result.stderr); expect(parsed.error).toBeDefined(); }); @@ -234,14 +243,14 @@ test("--input-json is registered as a global option", async () => { // stderr is the agent-mode signature, which proves --mode agent was applied. const result = await clerk.raw("--input-json", '{"mode":"agent"}', "doctor", "--json"); expect(result.exitCode).not.toBe(0); - const parsed = JSON.parse(result.stderr); + const parsed = parseJsonFromStderr(result.stderr); expect(parsed.error).toBeDefined(); }); test("structured JSON error in agent mode for invalid JSON", async () => { const result = await clerk.raw("--mode", "agent", "init", "--input-json", "{bad}"); expect(result.exitCode).not.toBe(0); - const parsed = JSON.parse(result.stderr); + const parsed = parseJsonFromStderr(result.stderr); expect(parsed.error.code).toBe("invalid_json"); }); @@ -254,7 +263,7 @@ test("structured JSON error in agent mode for file not found", async () => { "@/tmp/does-not-exist-clerk.json", ); expect(result.exitCode).not.toBe(0); - const parsed = JSON.parse(result.stderr); + const parsed = parseJsonFromStderr(result.stderr); expect(parsed.error.code).toBe("file_not_found"); }); @@ -267,6 +276,6 @@ test("structured JSON error in agent mode for nested objects", async () => { '{"nested":{"key":"value"}}', ); expect(result.exitCode).not.toBe(0); - const parsed = JSON.parse(result.stderr); + const parsed = parseJsonFromStderr(result.stderr); expect(parsed.error.code).toBe("invalid_json"); });