diff --git a/.changeset/api-fapi-passthrough.md b/.changeset/api-fapi-passthrough.md new file mode 100644 index 00000000..26a8c1c2 --- /dev/null +++ b/.changeset/api-fapi-passthrough.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk api --fapi` to call an instance's public Frontend API (e.g. `clerk api --fapi /environment --app `). The FAPI host is resolved from the instance's publishable key, and the request is unauthenticated since these endpoints are public, which closes the loop on verifying config changes end to end with the CLI alone. diff --git a/packages/cli-core/src/commands/api/README.md b/packages/cli-core/src/commands/api/README.md index c6b18809..cffc0d4d 100644 --- a/packages/cli-core/src/commands/api/README.md +++ b/packages/cli-core/src/commands/api/README.md @@ -55,22 +55,26 @@ clerk api /users --instance prod # Platform API mode clerk api /v1/platform/applications --platform + +# Frontend API mode — fetch the public environment payload to verify config +clerk api --fapi /environment --app app_123 --instance dev ``` ## Options -| Flag | Description | -| ----------------------- | ----------------------------------------------------------------- | -| `-X, --method ` | HTTP method. Defaults to GET, or POST if body is provided. | -| `-d, --data ` | JSON request body (inline) | -| `--file ` | Read request body from a file | -| `--include` | Show response status and headers | -| `--app ` | Application ID to target when resolving keys | -| `--secret-key ` | Override the secret key | -| `--instance ` | Instance to target for key resolution (`dev`, `prod`, or full ID) | -| `--platform` | Use Platform API instead of Backend API | -| `--dry-run` | Show request without executing | -| `--yes` | Skip confirmation for mutating requests | +| Flag | Description | +| ----------------------- | ------------------------------------------------------------------------------- | +| `-X, --method ` | HTTP method. Defaults to GET, or POST if body is provided. | +| `-d, --data ` | JSON request body (inline) | +| `--file ` | Read request body from a file | +| `--include` | Show response status and headers | +| `--app ` | Application ID to target when resolving keys | +| `--secret-key ` | Override the secret key | +| `--instance ` | Instance to target for key resolution (`dev`, `prod`, or full ID) | +| `--platform` | Use Platform API instead of Backend API | +| `--fapi` | Use the instance's public Frontend API (no auth; host from the publishable key) | +| `--dry-run` | Show request without executing | +| `--yes` | Skip confirmation for mutating requests | ## Authentication @@ -94,6 +98,17 @@ Platform API auth (used by `--platform` mode, and by steps 3 and 4 above): The CLI validates key prefixes and will warn if you pass an `ak_` key where an `sk_` key is expected, or vice versa. +### Frontend API (`--fapi`) + +`--fapi` targets the instance's public Frontend API — the same surface clerk-js +consumes — which is useful for verifying that a config change took effect (e.g. +`clerk api --fapi /environment`). The FAPI host is resolved from the instance's +publishable key, looked up via the Platform API from `--app`/`--instance` or the +linked project, so resolving the host needs Platform API auth, but the request +itself is unauthenticated (these endpoints are public). `--fapi` and `--platform` +cannot be combined. Paths are `/v1`-normalized like the other modes, so both +`/environment` and `/v1/environment` work. + ## API Endpoints ### Backend API (default) diff --git a/packages/cli-core/src/commands/api/bapi.ts b/packages/cli-core/src/commands/api/bapi.ts index 7b500951..d3d79495 100644 --- a/packages/cli-core/src/commands/api/bapi.ts +++ b/packages/cli-core/src/commands/api/bapi.ts @@ -6,14 +6,7 @@ import { getBapiBaseUrl } from "../../lib/environment.ts"; import { normalizeBapiPath } from "../../lib/bapi-command.ts"; import { BapiError } from "../../lib/errors.ts"; -import { loggedFetch } from "../../lib/fetch.ts"; - -export interface BapiResponse { - status: number; - headers: Headers; - body: unknown; - rawBody: string; -} +import { loggedFetch, type ApiResponse } from "../../lib/fetch.ts"; export async function bapiRequest(options: { method: string; @@ -21,7 +14,7 @@ export async function bapiRequest(options: { secretKey: string; body?: string; baseUrl?: string; -}): Promise { +}): Promise { const base = options.baseUrl ?? getBapiBaseUrl(); const path = normalizeBapiPath(options.path); diff --git a/packages/cli-core/src/commands/api/fapi.ts b/packages/cli-core/src/commands/api/fapi.ts new file mode 100644 index 00000000..8d5c2302 --- /dev/null +++ b/packages/cli-core/src/commands/api/fapi.ts @@ -0,0 +1,61 @@ +/** + * Instance + FAPI host resolution for `clerk api --fapi`. + * + * FAPI is the public API that clerk-js consumes. Its host is per-instance and + * derived from the instance's publishable key. The passthrough request itself + * lives in `lib/fapi.ts` (`fapiRequest`) alongside the other FAPI helpers. + */ + +import { resolveAppContext, resolveFetchedApplicationInstance } from "../../lib/config.ts"; +import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; +import { decodePublishableKey } from "../../lib/fapi.ts"; +import { fetchApplication, type ApplicationInstance } from "../../lib/plapi.ts"; + +interface ResolveOptions { + app?: string; + instance?: string; +} + +async function resolveInstance(options: ResolveOptions): Promise { + if (options.app) { + const app = await withApiContext(fetchApplication(options.app), "Failed to resolve instance"); + const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance); + if (!resolved.found) { + throw new CliError(`Instance ${resolved.instanceId} not found in application.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }); + } + return resolved.instance; + } + + let ctx: Awaited>; + try { + ctx = await resolveAppContext({ app: options.app, instance: options.instance }); + } catch (error) { + if (error instanceof CliError && error.code === ERROR_CODE.NOT_LINKED) { + throwUsageError( + "No instance found. Link a project with `clerk link`, or pass --app .", + "https://clerk.com/docs/guides/development/managing-environments", + ERROR_CODE.NOT_LINKED, + ); + } + throw error; + } + + const app = await withApiContext(fetchApplication(ctx.appId), "Failed to resolve instance"); + const resolved = resolveFetchedApplicationInstance(ctx.appId, app, ctx.instanceId); + if (!resolved.found) { + throw new CliError(`Instance ${ctx.instanceId} not found in application.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }); + } + return resolved.instance; +} + +/** Resolve the instance's FAPI host from its publishable key. */ +export async function resolveFapiHost(options: ResolveOptions): Promise { + const instance = await resolveInstance(options); + return decodePublishableKey(instance.publishable_key).fapiHost; +} diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index e2b59988..09e24481 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -476,6 +476,143 @@ describe("api command", () => { ); }); + // --- --fapi mode --- + + test("--fapi resolves the FAPI host from the publishable key and sends no auth header", async () => { + delete process.env.CLERK_SECRET_KEY; + process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform"; + const pk = `pk_test_${btoa("clerk.example.com$")}`; + let fapiUrl = ""; + let fapiAuth: string | null = "unset"; + + stubFetch(async (input, init) => { + const url = input.toString(); + if (url.includes("/v1/platform/applications/app_1")) { + return new Response( + JSON.stringify({ + application_id: "app_1", + instances: [ + { instance_id: "ins_dev", environment_type: "development", publishable_key: pk }, + ], + }), + { status: 200 }, + ); + } + fapiUrl = url; + fapiAuth = new Headers(init?.headers).get("Authorization"); + return new Response(JSON.stringify({ environment: "ok" }), { status: 200 }); + }); + + await runApi("/environment", { fapi: true, app: "app_1", instance: "dev" }); + expect(fapiUrl).toContain("https://clerk.example.com/v1/environment"); + expect(fapiUrl).toContain("_clerk_js_version="); + expect(fapiAuth).toBeNull(); + }); + + test("--fapi cannot be combined with --platform", async () => { + await expect(runApi("/environment", { fapi: true, platform: true })).rejects.toThrow( + "cannot be combined", + ); + }); + + test("--fapi prints API error response body to stdout and exits 1", async () => { + setMode("human"); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform"; + const pk = `pk_test_${btoa("clerk.example.com$")}`; + const errorBody = { errors: [{ message: "no environment", code: "not_found" }] }; + + stubFetch(async (input) => { + const url = input.toString(); + if (url.includes("/v1/platform/applications/app_1")) { + return new Response( + JSON.stringify({ + application_id: "app_1", + instances: [ + { instance_id: "ins_dev", environment_type: "development", publishable_key: pk }, + ], + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify(errorBody), { status: 404 }); + }); + + await runApi("/environment", { fapi: true, app: "app_1" }); + expect(process.exitCode).toBe(1); + expect(captured.out).toContain(JSON.stringify(errorBody, null, 2)); + }); + + test("--fapi without --app and no linked project errors with NOT_LINKED guidance", async () => { + delete process.env.CLERK_SECRET_KEY; + + await expect(runApi("/environment", { fapi: true })).rejects.toThrow(/clerk link|--app/); + }); + + test.each([ + ["/environment", "/v1/environment"], + ["/v1/environment", "/v1/environment"], + ])("--fapi: %s resolves to the same FAPI path %s", async (input, expectedPath) => { + process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform"; + const pk = `pk_test_${btoa("clerk.example.com$")}`; + let capturedPath = ""; + + stubFetch(async (input) => { + const url = input.toString(); + if (url.includes("/v1/platform/applications/app_1")) { + return new Response( + JSON.stringify({ + application_id: "app_1", + instances: [ + { instance_id: "ins_dev", environment_type: "development", publishable_key: pk }, + ], + }), + { status: 200 }, + ); + } + capturedPath = new URL(url).pathname; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await runApi(input, { fapi: true, app: "app_1" }); + expect(capturedPath).toBe(expectedPath); + }); + + test("--fapi + --secret-key emits a warning that --secret-key is ignored", async () => { + process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform"; + const pk = `pk_test_${btoa("clerk.example.com$")}`; + + stubFetch(async (input) => { + const url = input.toString(); + if (url.includes("/v1/platform/applications/app_1")) { + return new Response( + JSON.stringify({ + application_id: "app_1", + instances: [ + { instance_id: "ins_dev", environment_type: "development", publishable_key: pk }, + ], + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await runApi("/environment", { fapi: true, app: "app_1", secretKey: "sk_test_ignored" }); + expect(captured.err).toMatch(/--secret-key is ignored/); + }); + + test("--fapi + --dry-run does not make any network request", async () => { + let fetchCalled = false; + stubFetch(async () => { + fetchCalled = true; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await runApi("/environment", { fapi: true, app: "app_1", dryRun: true }); + expect(fetchCalled).toBe(false); + expect(captured.err).toContain("[dry-run] GET "); + }); + // --- Error handling --- test("errors when no secret key available", async () => { diff --git a/packages/cli-core/src/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index d20c8bce..37395273 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -2,9 +2,12 @@ import type { Program } from "../../cli-program.ts"; import { getAuthToken } from "../../lib/plapi.ts"; import { getBapiBaseUrl, getPlapiBaseUrl } from "../../lib/environment.ts"; import { normalizeBapiPath, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; +import { type ApiResponse } from "../../lib/fetch.ts"; import { bapiRequest } from "./bapi.ts"; +import { fapiRequest } from "../../lib/fapi.ts"; +import { resolveFapiHost } from "./fapi.ts"; import { - BapiError, + ApiError, ERROR_CODE, UserAbortError, isPromptExitError, @@ -25,12 +28,46 @@ export interface ApiOptions { secretKey?: string; instance?: string; platform?: boolean; + fapi?: boolean; dryRun?: boolean; yes?: boolean; } const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +type RunRequest = (req: { method: string; path: string; body?: string }) => Promise; + +/** Validate fapi flag combinations and emit warnings for ignored flags. */ +function validateFapiOptions(options: ApiOptions): void { + if (options.platform) { + throwUsageError("--fapi and --platform cannot be combined.", undefined, ERROR_CODE.USAGE_ERROR); + } + if (options.secretKey) { + log.warn("--secret-key is ignored when --fapi is set."); + } +} + +/** Resolve the API surface (base URL + request executor) from the flags. */ +async function resolveApiTarget( + options: ApiOptions, +): Promise<{ baseUrl: string; runRequest: RunRequest }> { + if (options.fapi) { + const fapiHost = await resolveFapiHost(options); + const baseUrl = `https://${fapiHost}`; + return { baseUrl, runRequest: (req) => fapiRequest({ ...req, fapiHost }) }; + } + + if (options.platform) { + const secretKey = await getAuthToken(); + const baseUrl = getPlapiBaseUrl(); + return { baseUrl, runRequest: (req) => bapiRequest({ ...req, secretKey, baseUrl }) }; + } + + const secretKey = await resolveBapiSecretKey(options); + const baseUrl = getBapiBaseUrl(); + return { baseUrl, runRequest: (req) => bapiRequest({ ...req, secretKey, baseUrl }) }; +} + export async function api( endpoint: string | undefined, filter: string | undefined, @@ -61,27 +98,27 @@ export async function api( // 2. Determine HTTP method const method = (options.method ?? (body ? "POST" : "GET")).toUpperCase(); - // 3. Resolve authentication - let secretKey: string; - let baseUrl: string; - - if (options.platform) { - secretKey = await getAuthToken(); - baseUrl = getPlapiBaseUrl(); - } else { - secretKey = await resolveBapiSecretKey(options); - baseUrl = getBapiBaseUrl(); - } - - // 4. Dry run + // 3. Dry run — for --fapi, skip host resolution to avoid a real Platform API round-trip if (options.dryRun) { - log.info(`[dry-run] ${method} ${baseUrl}${normalizeBapiPath(endpoint)}`); + if (options.fapi) { + validateFapiOptions(options); + log.info(`[dry-run] ${method} ${normalizeBapiPath(endpoint)}`); + } else { + const { baseUrl } = await resolveApiTarget(options); + log.info(`[dry-run] ${method} ${baseUrl}${normalizeBapiPath(endpoint)}`); + } if (body) { prettyPrint(body); } return; } + // 4. Resolve the request target (base URL + executor) + if (options.fapi) { + validateFapiOptions(options); + } + const { runRequest } = await resolveApiTarget(options); + // 5. Confirmation for mutating methods if (MUTATING_METHODS.has(method) && isHuman() && !options.yes) { log.info(`\nAbout to ${method} ${endpoint}`); @@ -97,13 +134,7 @@ export async function api( // 6. Execute request try { const response = await withSpinner("Executing request...", () => - bapiRequest({ - method, - path: endpoint, - secretKey, - body: body ?? undefined, - baseUrl, - }), + runRequest({ method, path: endpoint, body: body ?? undefined }), ); if (options.include) { @@ -112,10 +143,10 @@ export async function api( printBody(response.body); closeStatus = "success"; } catch (error) { - // Handle BapiError locally to print the raw API response body to stdout + // Handle API errors locally to print the raw response body to stdout // (for piping), rather than propagating to the global error handler. - if (error instanceof BapiError) { - if (options.include) { + if (error instanceof ApiError) { + if (options.include && error.headers) { printHeaders(error.status, error.headers); } prettyPrint(error.body); @@ -216,6 +247,10 @@ export function registerApi(program: Program): void { .option("--secret-key ", "Override the secret key") .option("--instance ", "Instance to target (dev, prod, or instance ID)") .option("--platform", "Use Platform API instead of Backend API") + .option( + "--fapi", + "Use the instance's public Frontend API (unauthenticated endpoints only; host derived from the publishable key)", + ) .option("--dry-run", "Show the request without executing it") .option("--yes", "Skip confirmation for mutating requests") .setExamples([ @@ -226,6 +261,10 @@ export function registerApi(program: Program): void { command: 'clerk api /users -d \'{"first_name":"Alice"}\'', description: "POST with a JSON body", }, + { + command: "clerk api --fapi /environment --app --instance dev", + description: "GET the public FAPI environment payload", + }, ]) .action(api); } diff --git a/packages/cli-core/src/lib/fapi.test.ts b/packages/cli-core/src/lib/fapi.test.ts index 2e7c4291..08dba7d1 100644 --- a/packages/cli-core/src/lib/fapi.test.ts +++ b/packages/cli-core/src/lib/fapi.test.ts @@ -102,7 +102,7 @@ describe("fetchUserSettings", () => { expect(settings.attributes.email_address?.enabled).toBe(true); expect(capturedUrl).toContain("https://foo.example.com/v1/environment"); expect(capturedUrl).toContain("__clerk_db_jwt=jwt-abc"); - expect(capturedUrl).toContain("_clerk_js_version=5"); + expect(capturedUrl).toContain("_clerk_js_version=6"); }); test("omits __clerk_db_jwt when no jwt is provided", async () => { diff --git a/packages/cli-core/src/lib/fapi.ts b/packages/cli-core/src/lib/fapi.ts index 7b9ad8d9..ff064e20 100644 --- a/packages/cli-core/src/lib/fapi.ts +++ b/packages/cli-core/src/lib/fapi.ts @@ -5,8 +5,9 @@ */ import type { UserSettingsJSON } from "@clerk/shared/types"; +import { normalizeBapiPath } from "./bapi-command.ts"; import { CliError, FapiError, ERROR_CODE } from "./errors.ts"; -import { loggedFetch } from "./fetch.ts"; +import { loggedFetch, type ApiResponse } from "./fetch.ts"; const PK_TEST_PREFIX = "pk_test_"; const PK_LIVE_PREFIX = "pk_live_"; @@ -15,7 +16,7 @@ const PK_LIVE_PREFIX = "pk_live_"; * The clerk-js client version FAPI's `/v1/environment` payload is shaped for. * Bump when consuming response fields introduced in a later major version. */ -const CLERK_JS_API_VERSION = "5"; +export const CLERK_JS_API_VERSION = "6"; export type InstanceType = "development" | "production"; @@ -120,3 +121,45 @@ export async function fetchUserSettings( } return body.user_settings; } + +/** + * Passthrough request used by `clerk api --fapi`. Unlike the typed helpers + * above, this issues an arbitrary request against the instance's FAPI host and + * returns the normalized response untouched. FAPI endpoints exposed here are + * public, so no auth header is sent. + */ +export async function fapiRequest(options: { + method: string; + path: string; + fapiHost: string; + body?: string; +}): Promise { + const url = new URL(`https://${options.fapiHost}${normalizeBapiPath(options.path)}`); + if (!url.searchParams.has("_clerk_js_version")) { + url.searchParams.set("_clerk_js_version", CLERK_JS_API_VERSION); + } + + const headers: Record = { Accept: "application/json" }; + if (options.body) headers["Content-Type"] = "application/json"; + + const response = await loggedFetch(url, { + tag: "fapi", + method: options.method, + headers, + body: options.body, + }); + + if (!response.ok) { + throw await FapiError.fromResponse(response); + } + + const rawBody = await response.text(); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + body = rawBody; + } + + return { status: response.status, headers: response.headers, body, rawBody }; +} diff --git a/packages/cli-core/src/lib/fetch.ts b/packages/cli-core/src/lib/fetch.ts index 1c5a8803..ffebe505 100644 --- a/packages/cli-core/src/lib/fetch.ts +++ b/packages/cli-core/src/lib/fetch.ts @@ -16,6 +16,18 @@ const USER_AGENT = buildUserAgent(); export type LoggedFetchInit = RequestInit & { tag: string }; +/** + * Normalized response shape returned by the higher-level API request wrappers + * (`bapiRequest`, `fapiRequest`). `body` is the parsed JSON when the payload is + * valid JSON, otherwise the raw string; `rawBody` is always the unparsed text. + */ +export interface ApiResponse { + status: number; + headers: Headers; + body: unknown; + rawBody: string; +} + export async function loggedFetch(url: URL | string, options: LoggedFetchInit): Promise { const { tag, ...init } = options; const method = init.method ?? "GET";