diff --git a/.changeset/platform-api-url-credential-warning.md b/.changeset/platform-api-url-credential-warning.md new file mode 100644 index 000000000..a34a01854 --- /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 9ea89cb54..b3d226d98 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -29,6 +29,7 @@ import { getCurrentEnvName, getAvailableEnvs, getPlapiBaseUrl, + getPlatformApiUrlOverride, } from "./lib/environment.ts"; import { CliError, @@ -41,7 +42,7 @@ import { throwUsageError, } from "./lib/errors.ts"; import { clerkHelpConfig, formatExamplesBlock, type Example } from "./lib/help.ts"; -import { isAgent } from "./mode.ts"; +import { isAgent, isHuman } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { registerExtras } from "@clerk/cli-extras"; @@ -135,6 +136,19 @@ export function createProgram(): Program { if (activeEnv !== "production") { process.stderr.write(`[${activeEnv.toUpperCase()}]\n`); } + + // Warn (human mode only) 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. Agent/scripted mode stays off stderr — stray lines there + // corrupt machine-readable output — but still gets the info via `log.debug` + // so a `--verbose` re-run of a failing script surfaces it. + const override = getPlatformApiUrlOverride(); + if (override.overridden) { + const msg = `CLERK_PLATFORM_API_URL is routing requests to ${override.overrideUrl} instead of the "${override.envName}" environment's ${override.profileUrl} — the "${override.envName}" token will be sent to that host.`; + if (isHuman()) log.warn(msg); + else log.debug(`env: ${msg}`); + } }); // Show update notification after each command, except for commands that @@ -215,10 +229,28 @@ async function resolveArgv( ): Promise<{ argv: string[]; from: ParseFrom }> { const raw = args ?? process.argv; const effectiveFrom = from ?? (args === undefined ? "node" : "user"); + // Honor an explicit `--mode` flag before `expandInputJson`, so a failure it + // throws (invalid JSON, missing file) is formatted in the right mode. The + // preAction hook re-applies and validates `--mode`, but it runs during + // parseAsync — too late for errors raised while resolving argv. + applyForcedModeFromArgv(raw); const argv = await expandInputJson([...raw]); return { argv, from: effectiveFrom }; } +/** + * Scan raw argv for an explicit `--mode human|agent` and force it early. + * Ignores an invalid value — the preAction hook reports that as a usage error. + */ +function applyForcedModeFromArgv(argv: string[]): void { + const idx = argv.indexOf("--mode"); + if (idx === -1) return; + const value = argv[idx + 1]; + if (value === "human" || value === "agent") { + setMode(value); + } +} + /** * Parse and run a program, handling all typed errors with user-facing messages. * Used by `cli.ts` for real execution and by integration tests. diff --git a/packages/cli-core/src/commands/doctor/checks.ts b/packages/cli-core/src/commands/doctor/checks.ts index 6fa63ec35..bae02b98f 100644 --- a/packages/cli-core/src/commands/doctor/checks.ts +++ b/packages/cli-core/src/commands/doctor/checks.ts @@ -18,6 +18,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, KeylessInstanceInfo } from "./types.ts"; @@ -111,7 +112,9 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise { fixable: false, }); } - return check.pass("Logged in (token found in credential store)"); + return check.pass( + `Logged in (token found in credential store) — environment "${getCurrentEnvName()}", API ${getPlapiBaseUrl()}`, + ); } // No account session doesn't mean the project is broken: an unclaimed 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 000000000..e8872afff --- /dev/null +++ b/packages/cli-core/src/lib/environment.test.ts @@ -0,0 +1,51 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { getPlatformApiUrlOverride } from "./environment.ts"; + +describe("getPlatformApiUrlOverride", () => { + 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 = getPlatformApiUrlOverride(); + 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 = getPlatformApiUrlOverride(); + 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 = getPlatformApiUrlOverride(); + 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 = getPlatformApiUrlOverride(); + expect(result.overridden).toBe(false); + }); + + test("falls back to raw string comparison when the override is not a valid URL", () => { + process.env.CLERK_PLATFORM_API_URL = "not a url"; + const result = getPlatformApiUrlOverride(); + expect(result.overridden).toBe(true); + if (!result.overridden) return; + expect(result.overrideUrl).toBe("not a url"); + expect(result.profileUrl).toBe("https://api.clerk.com"); + }); +}); diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index a695badd3..019e65641 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -39,6 +39,19 @@ const DEFAULT_PROFILES: Record = { let currentEnvName: string | undefined; let profilesSourceLogged = false; +/** + * Test-only fields shallow-merged into every resolved profile. The integration + * harness uses this to align the active profile's `platformApiUrl` with the + * test `CLERK_PLATFORM_API_URL`, so it stops tripping the override warning it + * never means to exercise. Mirrors config.ts's `_setConfigDir`. + */ +let testProfileOverride: Partial | undefined; + +/** @internal Test-only. Pass `undefined` to restore normal profile resolution. */ +export function _setProfileOverrideForTest(override: Partial | undefined): void { + testProfileOverride = override; +} + function loadFileProfiles(): Record | undefined { // Try repo root (cwd) first, then fall back to path relative to this source file const candidates = [ @@ -62,6 +75,15 @@ function loadFileProfiles(): Record | undefined { } function getProfiles(): Record { + const base = resolveProfiles(); + if (!testProfileOverride) return base; + const override = testProfileOverride; + return Object.fromEntries( + Object.entries(base).map(([name, profile]) => [name, { ...profile, ...override }]), + ); +} + +function resolveProfiles(): Record { if (typeof CLI_ENV_PROFILES !== "undefined" && CLI_ENV_PROFILES) { if (!profilesSourceLogged) { profilesSourceLogged = true; @@ -141,6 +163,42 @@ export function getPlapiBaseUrl(): string { return process.env.CLERK_PLATFORM_API_URL ?? getCurrentEnv().platformApiUrl; } +/** + * Checks 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 getPlatformApiUrlOverride(): + | { + 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/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index 6d80b75cf..89df445ca 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -271,6 +271,17 @@ export async function setProfile( return (await getConfigModule()).setProfile(...args); } +// ── Real environment module ────────────────────────────────────────────────── + +type EnvironmentModule = typeof import("../../../lib/environment.ts"); + +let environmentModulePromise: Promise | null = null; + +function getEnvironmentModule(): Promise { + environmentModulePromise ??= import("../../../lib/environment.ts"); + return environmentModulePromise; +} + // ── Mock data ──────────────────────────────────────────────────────────────── /** @@ -539,10 +550,18 @@ export async function setupTest(): Promise { const tempDir = await mkdtemp(join(tmpdir(), "clerk-integration-")); const { _setConfigDir } = await getConfigModule(); _setConfigDir(tempDir); + // `_mode` is module-level state in the mode.ts mock; reset it so a prior + // test that switched to agent mode can't leak `--mode agent` into the next. + _mode = "human"; process.cwd = () => tempDir; setEnv("CLERK_PLATFORM_API_KEY", "test_platform_key"); setEnv("CLERK_PLATFORM_API_URL", "https://test-api.clerk.com"); setEnv("CLERK_BACKEND_API_URL", "https://test-bapi.clerk.dev"); + // Point the active profile's platform URL at the same host as the override + // above, so `getPlatformApiUrlOverride()` sees no mismatch and the CLI's + // credential-mismatch warning never fires as a stray first line of stderr. + const { _setProfileOverrideForTest } = await getEnvironmentModule(); + _setProfileOverrideForTest({ platformApiUrl: "https://test-api.clerk.com" }); mockState.storedToken = "mock_token"; mockState.gitNormalizedRemote = "github.com/test/project"; mockState.gitRepoRoot = "/repo"; @@ -572,11 +591,13 @@ export async function setupTest(): Promise { */ export async function teardownTest(harness: TestHarness): Promise { const { _setConfigDir } = await getConfigModule(); + const { _setProfileOverrideForTest } = await getEnvironmentModule(); currentHarness = null; setActiveCapture(null); assertPromptQueuesEmpty(); http.assertRoutesConsumed(); _setConfigDir(undefined); + _setProfileOverrideForTest(undefined); process.cwd = originalCwd; for (const [key, original] of envMutations) { if (original === undefined) {