Skip to content
Open
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/platform-api-url-credential-warning.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getCurrentEnvName,
getAvailableEnvs,
getPlapiBaseUrl,
isPlatformApiUrlOverridden,
} from "./lib/environment.ts";
import {
CliError,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-core/src/commands/doctor/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -74,7 +75,7 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
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<CheckResult> {
Expand Down
42 changes: 42 additions & 0 deletions packages/cli-core/src/lib/environment.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});

Comment thread
rafa-thayto marked this conversation as resolved.
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);
});
});
36 changes: 36 additions & 0 deletions packages/cli-core/src/lib/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion packages/cli-core/src/test/integration/error-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
19 changes: 14 additions & 5 deletions packages/cli-core/src/test/integration/input-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -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();
});

Expand Down Expand Up @@ -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");
});

Expand All @@ -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");
});

Expand All @@ -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");
});