From 020a2f5f8ec09e84943291dd234f415aef6476d6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 16 Jun 2026 20:09:43 -0300 Subject: [PATCH 1/4] feat(api): add --fapi to call the public Frontend API clerk api spoke only BAPI and PLAPI, so verifying a config change against the instance's public FAPI /v1/environment (what clerk-js consumes) meant dropping to curl and decoding the FAPI domain out of the publishable key by hand. Add --fapi: resolve the FAPI host from the instance's publishable key (via --app/--instance or the linked project) and do an unauthenticated passthrough, reusing the existing lib/fapi.ts client. --fapi and --platform are mutually exclusive. Closes #332 --- .changeset/api-fapi-passthrough.md | 5 + packages/cli-core/src/commands/api/README.md | 39 +++++-- packages/cli-core/src/commands/api/fapi.ts | 109 ++++++++++++++++++ .../cli-core/src/commands/api/index.test.ts | 66 +++++++++++ packages/cli-core/src/commands/api/index.ts | 71 ++++++++---- 5 files changed, 255 insertions(+), 35 deletions(-) create mode 100644 .changeset/api-fapi-passthrough.md create mode 100644 packages/cli-core/src/commands/api/fapi.ts 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/fapi.ts b/packages/cli-core/src/commands/api/fapi.ts new file mode 100644 index 00000000..29df1065 --- /dev/null +++ b/packages/cli-core/src/commands/api/fapi.ts @@ -0,0 +1,109 @@ +/** + * Frontend API (FAPI) passthrough 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, and the endpoints exposed here + * (e.g. `/v1/environment`) are public, so no auth header is sent. + */ + +import { resolveAppContext, resolveFetchedApplicationInstance } from "../../lib/config.ts"; +import { normalizeBapiPath } from "../../lib/bapi-command.ts"; +import { + CliError, + ERROR_CODE, + FapiError, + throwUsageError, + withApiContext, +} from "../../lib/errors.ts"; +import { decodePublishableKey } from "../../lib/fapi.ts"; +import { loggedFetch } from "../../lib/fetch.ts"; +import { fetchApplication, type ApplicationInstance } from "../../lib/plapi.ts"; +import type { BapiResponse } from "./bapi.ts"; + +/** clerk-js API version FAPI shapes its `/v1/environment` payload for. */ +const CLERK_JS_API_VERSION = "5"; + +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 instance = app.instances.find((entry) => entry.instance_id === ctx.instanceId); + if (!instance) { + 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 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; +} + +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/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index e2b59988..50b51b0d 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -476,6 +476,72 @@ 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)); + }); + // --- 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..086dc6de 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -2,9 +2,10 @@ 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 { bapiRequest } from "./bapi.ts"; +import { bapiRequest, type BapiResponse } from "./bapi.ts"; +import { fapiRequest, resolveFapiHost } from "./fapi.ts"; import { - BapiError, + ApiError, ERROR_CODE, UserAbortError, isPromptExitError, @@ -25,12 +26,43 @@ 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; + +/** Resolve the API surface (base URL + request executor) from the flags. */ +async function resolveApiTarget( + options: ApiOptions, +): Promise<{ baseUrl: string; runRequest: RunRequest }> { + if (options.fapi) { + if (options.platform) { + throwUsageError( + "--fapi and --platform cannot be combined.", + undefined, + ERROR_CODE.USAGE_ERROR, + ); + } + 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,17 +93,8 @@ 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(); - } + // 3. Resolve the request target (base URL + executor) + const { baseUrl, runRequest } = await resolveApiTarget(options); // 4. Dry run if (options.dryRun) { @@ -97,13 +120,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 +129,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 +233,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 (no auth; host resolved from the publishable key)", + ) .option("--dry-run", "Show the request without executing it") .option("--yes", "Skip confirmation for mutating requests") .setExamples([ @@ -226,6 +247,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); } From 64f6f757af9a1f2a49b1a6e48a3cf7bb07435346 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 18 Jun 2026 09:19:13 -0300 Subject: [PATCH 2/4] fix(api): address review feedback on --fapi passthrough - Move dry-run check before resolveFapiHost so --fapi --dry-run avoids the Platform API round-trip; shows placeholder instead - Import CLERK_JS_API_VERSION from lib/fapi.ts instead of redeclaring it - Warn when --secret-key is provided with --fapi (key is ignored) - Add tests: --fapi no-app NOT_LINKED error, /environment and /v1/environment path normalization, --secret-key warning, --dry-run no network call --- packages/cli-core/src/commands/api/fapi.ts | 5 +- .../cli-core/src/commands/api/index.test.ts | 71 +++++++++++++++++++ packages/cli-core/src/commands/api/index.ts | 36 ++++++---- packages/cli-core/src/lib/fapi.ts | 2 +- 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/packages/cli-core/src/commands/api/fapi.ts b/packages/cli-core/src/commands/api/fapi.ts index 29df1065..6a49581e 100644 --- a/packages/cli-core/src/commands/api/fapi.ts +++ b/packages/cli-core/src/commands/api/fapi.ts @@ -15,14 +15,11 @@ import { throwUsageError, withApiContext, } from "../../lib/errors.ts"; -import { decodePublishableKey } from "../../lib/fapi.ts"; +import { decodePublishableKey, CLERK_JS_API_VERSION } from "../../lib/fapi.ts"; import { loggedFetch } from "../../lib/fetch.ts"; import { fetchApplication, type ApplicationInstance } from "../../lib/plapi.ts"; import type { BapiResponse } from "./bapi.ts"; -/** clerk-js API version FAPI shapes its `/v1/environment` payload for. */ -const CLERK_JS_API_VERSION = "5"; - interface ResolveOptions { app?: string; instance?: string; diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index 50b51b0d..09e24481 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -542,6 +542,77 @@ describe("api command", () => { 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 086dc6de..2a71838a 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -35,18 +35,21 @@ 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) { - if (options.platform) { - throwUsageError( - "--fapi and --platform cannot be combined.", - undefined, - ERROR_CODE.USAGE_ERROR, - ); - } const fapiHost = await resolveFapiHost(options); const baseUrl = `https://${fapiHost}`; return { baseUrl, runRequest: (req) => fapiRequest({ ...req, fapiHost }) }; @@ -93,18 +96,27 @@ export async function api( // 2. Determine HTTP method const method = (options.method ?? (body ? "POST" : "GET")).toUpperCase(); - // 3. Resolve the request target (base URL + executor) - const { baseUrl, runRequest } = await resolveApiTarget(options); - - // 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}`); diff --git a/packages/cli-core/src/lib/fapi.ts b/packages/cli-core/src/lib/fapi.ts index 7b9ad8d9..9dd1bf56 100644 --- a/packages/cli-core/src/lib/fapi.ts +++ b/packages/cli-core/src/lib/fapi.ts @@ -15,7 +15,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 = "5"; export type InstanceType = "development" | "production"; From e1b5db30c4c42e6e2d033758407267c0e04e9ec5 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 19 Jun 2026 09:16:47 -0300 Subject: [PATCH 3/4] fix(api): address remaining review feedback on --fapi passthrough - Refactor fallback branch of resolveInstance to use resolveFetchedApplicationInstance instead of hand-rolling app.instances.find (addresses wyattjoh comment 3) - Bump CLERK_JS_API_VERSION from "5" to "6" to match current clerk-js major (addresses dmoerner comment 6) - Clarify --fapi help text: "unauthenticated endpoints only" instead of "no auth" to avoid implying it skips auth on authenticated endpoints (addresses dmoerner comment 7) --- packages/cli-core/src/commands/api/fapi.ts | 6 +++--- packages/cli-core/src/commands/api/index.ts | 2 +- packages/cli-core/src/lib/fapi.test.ts | 2 +- packages/cli-core/src/lib/fapi.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/api/fapi.ts b/packages/cli-core/src/commands/api/fapi.ts index 6a49581e..97388d5a 100644 --- a/packages/cli-core/src/commands/api/fapi.ts +++ b/packages/cli-core/src/commands/api/fapi.ts @@ -53,14 +53,14 @@ async function resolveInstance(options: ResolveOptions): Promise entry.instance_id === ctx.instanceId); - if (!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 instance; + return resolved.instance; } /** Resolve the instance's FAPI host from its publishable key. */ diff --git a/packages/cli-core/src/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index 2a71838a..9e658572 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -247,7 +247,7 @@ export function registerApi(program: Program): void { .option("--platform", "Use Platform API instead of Backend API") .option( "--fapi", - "Use the instance's public Frontend API (no auth; host resolved from the publishable key)", + "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") 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 9dd1bf56..f67d5f4e 100644 --- a/packages/cli-core/src/lib/fapi.ts +++ b/packages/cli-core/src/lib/fapi.ts @@ -15,7 +15,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. */ -export const CLERK_JS_API_VERSION = "5"; +export const CLERK_JS_API_VERSION = "6"; export type InstanceType = "development" | "production"; From efa6d41a1bbd524345bb9b0eac8d0922474436a3 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 22 Jun 2026 16:20:02 -0300 Subject: [PATCH 4/4] refactor(api): move fapiRequest into lib/fapi.ts Co-locate the FAPI passthrough request with the other FAPI helpers (bootstrapDevBrowser, fetchUserSettings) in lib/fapi.ts, where it already reached for decodePublishableKey and CLERK_JS_API_VERSION. Promote the passthrough response shape to a shared `ApiResponse` type in lib/fetch.ts so the moved function does not have to import upward from commands/. commands/api/fapi.ts keeps the command-layer instance and host resolution. Addresses review feedback on #345. Claude-Session: https://claude.ai/code/session_01QnfBw9qY7u19BvUWyfQGC6 --- packages/cli-core/src/commands/api/bapi.ts | 11 +---- packages/cli-core/src/commands/api/fapi.ts | 55 ++------------------- packages/cli-core/src/commands/api/index.ts | 8 +-- packages/cli-core/src/lib/fapi.ts | 45 ++++++++++++++++- packages/cli-core/src/lib/fetch.ts | 12 +++++ 5 files changed, 68 insertions(+), 63 deletions(-) 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 index 97388d5a..8d5c2302 100644 --- a/packages/cli-core/src/commands/api/fapi.ts +++ b/packages/cli-core/src/commands/api/fapi.ts @@ -1,24 +1,15 @@ /** - * Frontend API (FAPI) passthrough for `clerk api --fapi`. + * 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, and the endpoints exposed here - * (e.g. `/v1/environment`) are public, so no auth header is sent. + * 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 { normalizeBapiPath } from "../../lib/bapi-command.ts"; -import { - CliError, - ERROR_CODE, - FapiError, - throwUsageError, - withApiContext, -} from "../../lib/errors.ts"; -import { decodePublishableKey, CLERK_JS_API_VERSION } from "../../lib/fapi.ts"; -import { loggedFetch } from "../../lib/fetch.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"; -import type { BapiResponse } from "./bapi.ts"; interface ResolveOptions { app?: string; @@ -68,39 +59,3 @@ export async function resolveFapiHost(options: ResolveOptions): Promise const instance = await resolveInstance(options); return decodePublishableKey(instance.publishable_key).fapiHost; } - -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/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index 9e658572..37395273 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -2,8 +2,10 @@ 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 { bapiRequest, type BapiResponse } from "./bapi.ts"; -import { fapiRequest, resolveFapiHost } from "./fapi.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 { ApiError, ERROR_CODE, @@ -33,7 +35,7 @@ export interface ApiOptions { const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); -type RunRequest = (req: { method: string; path: string; body?: string }) => Promise; +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 { diff --git a/packages/cli-core/src/lib/fapi.ts b/packages/cli-core/src/lib/fapi.ts index f67d5f4e..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_"; @@ -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";