Skip to content

Commit 020a2f5

Browse files
committed
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
1 parent 4240dd5 commit 020a2f5

5 files changed

Lines changed: 255 additions & 35 deletions

File tree

.changeset/api-fapi-passthrough.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": minor
3+
---
4+
5+
Add `clerk api --fapi` to call an instance's public Frontend API (e.g. `clerk api --fapi /environment --app <id>`). 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.

packages/cli-core/src/commands/api/README.md

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,26 @@ clerk api /users --instance prod
5555

5656
# Platform API mode
5757
clerk api /v1/platform/applications --platform
58+
59+
# Frontend API mode — fetch the public environment payload to verify config
60+
clerk api --fapi /environment --app app_123 --instance dev
5861
```
5962

6063
## Options
6164

62-
| Flag | Description |
63-
| ----------------------- | ----------------------------------------------------------------- |
64-
| `-X, --method <method>` | HTTP method. Defaults to GET, or POST if body is provided. |
65-
| `-d, --data <json>` | JSON request body (inline) |
66-
| `--file <path>` | Read request body from a file |
67-
| `--include` | Show response status and headers |
68-
| `--app <id>` | Application ID to target when resolving keys |
69-
| `--secret-key <key>` | Override the secret key |
70-
| `--instance <id>` | Instance to target for key resolution (`dev`, `prod`, or full ID) |
71-
| `--platform` | Use Platform API instead of Backend API |
72-
| `--dry-run` | Show request without executing |
73-
| `--yes` | Skip confirmation for mutating requests |
65+
| Flag | Description |
66+
| ----------------------- | ------------------------------------------------------------------------------- |
67+
| `-X, --method <method>` | HTTP method. Defaults to GET, or POST if body is provided. |
68+
| `-d, --data <json>` | JSON request body (inline) |
69+
| `--file <path>` | Read request body from a file |
70+
| `--include` | Show response status and headers |
71+
| `--app <id>` | Application ID to target when resolving keys |
72+
| `--secret-key <key>` | Override the secret key |
73+
| `--instance <id>` | Instance to target for key resolution (`dev`, `prod`, or full ID) |
74+
| `--platform` | Use Platform API instead of Backend API |
75+
| `--fapi` | Use the instance's public Frontend API (no auth; host from the publishable key) |
76+
| `--dry-run` | Show request without executing |
77+
| `--yes` | Skip confirmation for mutating requests |
7478

7579
## Authentication
7680

@@ -94,6 +98,17 @@ Platform API auth (used by `--platform` mode, and by steps 3 and 4 above):
9498

9599
The CLI validates key prefixes and will warn if you pass an `ak_` key where an `sk_` key is expected, or vice versa.
96100

101+
### Frontend API (`--fapi`)
102+
103+
`--fapi` targets the instance's public Frontend API — the same surface clerk-js
104+
consumes — which is useful for verifying that a config change took effect (e.g.
105+
`clerk api --fapi /environment`). The FAPI host is resolved from the instance's
106+
publishable key, looked up via the Platform API from `--app`/`--instance` or the
107+
linked project, so resolving the host needs Platform API auth, but the request
108+
itself is unauthenticated (these endpoints are public). `--fapi` and `--platform`
109+
cannot be combined. Paths are `/v1`-normalized like the other modes, so both
110+
`/environment` and `/v1/environment` work.
111+
97112
## API Endpoints
98113

99114
### Backend API (default)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Frontend API (FAPI) passthrough for `clerk api --fapi`.
3+
*
4+
* FAPI is the public API that clerk-js consumes. Its host is per-instance and
5+
* derived from the instance's publishable key, and the endpoints exposed here
6+
* (e.g. `/v1/environment`) are public, so no auth header is sent.
7+
*/
8+
9+
import { resolveAppContext, resolveFetchedApplicationInstance } from "../../lib/config.ts";
10+
import { normalizeBapiPath } from "../../lib/bapi-command.ts";
11+
import {
12+
CliError,
13+
ERROR_CODE,
14+
FapiError,
15+
throwUsageError,
16+
withApiContext,
17+
} from "../../lib/errors.ts";
18+
import { decodePublishableKey } from "../../lib/fapi.ts";
19+
import { loggedFetch } from "../../lib/fetch.ts";
20+
import { fetchApplication, type ApplicationInstance } from "../../lib/plapi.ts";
21+
import type { BapiResponse } from "./bapi.ts";
22+
23+
/** clerk-js API version FAPI shapes its `/v1/environment` payload for. */
24+
const CLERK_JS_API_VERSION = "5";
25+
26+
interface ResolveOptions {
27+
app?: string;
28+
instance?: string;
29+
}
30+
31+
async function resolveInstance(options: ResolveOptions): Promise<ApplicationInstance> {
32+
if (options.app) {
33+
const app = await withApiContext(fetchApplication(options.app), "Failed to resolve instance");
34+
const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance);
35+
if (!resolved.found) {
36+
throw new CliError(`Instance ${resolved.instanceId} not found in application.`, {
37+
code: ERROR_CODE.INSTANCE_NOT_FOUND,
38+
docsUrl: "https://clerk.com/docs/guides/development/managing-environments",
39+
});
40+
}
41+
return resolved.instance;
42+
}
43+
44+
let ctx: Awaited<ReturnType<typeof resolveAppContext>>;
45+
try {
46+
ctx = await resolveAppContext({ app: options.app, instance: options.instance });
47+
} catch (error) {
48+
if (error instanceof CliError && error.code === ERROR_CODE.NOT_LINKED) {
49+
throwUsageError(
50+
"No instance found. Link a project with `clerk link`, or pass --app <app_id>.",
51+
"https://clerk.com/docs/guides/development/managing-environments",
52+
ERROR_CODE.NOT_LINKED,
53+
);
54+
}
55+
throw error;
56+
}
57+
58+
const app = await withApiContext(fetchApplication(ctx.appId), "Failed to resolve instance");
59+
const instance = app.instances.find((entry) => entry.instance_id === ctx.instanceId);
60+
if (!instance) {
61+
throw new CliError(`Instance ${ctx.instanceId} not found in application.`, {
62+
code: ERROR_CODE.INSTANCE_NOT_FOUND,
63+
docsUrl: "https://clerk.com/docs/guides/development/managing-environments",
64+
});
65+
}
66+
return instance;
67+
}
68+
69+
/** Resolve the instance's FAPI host from its publishable key. */
70+
export async function resolveFapiHost(options: ResolveOptions): Promise<string> {
71+
const instance = await resolveInstance(options);
72+
return decodePublishableKey(instance.publishable_key).fapiHost;
73+
}
74+
75+
export async function fapiRequest(options: {
76+
method: string;
77+
path: string;
78+
fapiHost: string;
79+
body?: string;
80+
}): Promise<BapiResponse> {
81+
const url = new URL(`https://${options.fapiHost}${normalizeBapiPath(options.path)}`);
82+
if (!url.searchParams.has("_clerk_js_version")) {
83+
url.searchParams.set("_clerk_js_version", CLERK_JS_API_VERSION);
84+
}
85+
86+
const headers: Record<string, string> = { Accept: "application/json" };
87+
if (options.body) headers["Content-Type"] = "application/json";
88+
89+
const response = await loggedFetch(url, {
90+
tag: "fapi",
91+
method: options.method,
92+
headers,
93+
body: options.body,
94+
});
95+
96+
if (!response.ok) {
97+
throw await FapiError.fromResponse(response);
98+
}
99+
100+
const rawBody = await response.text();
101+
let body: unknown;
102+
try {
103+
body = JSON.parse(rawBody);
104+
} catch {
105+
body = rawBody;
106+
}
107+
108+
return { status: response.status, headers: response.headers, body, rawBody };
109+
}

packages/cli-core/src/commands/api/index.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,72 @@ describe("api command", () => {
476476
);
477477
});
478478

479+
// --- --fapi mode ---
480+
481+
test("--fapi resolves the FAPI host from the publishable key and sends no auth header", async () => {
482+
delete process.env.CLERK_SECRET_KEY;
483+
process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform";
484+
const pk = `pk_test_${btoa("clerk.example.com$")}`;
485+
let fapiUrl = "";
486+
let fapiAuth: string | null = "unset";
487+
488+
stubFetch(async (input, init) => {
489+
const url = input.toString();
490+
if (url.includes("/v1/platform/applications/app_1")) {
491+
return new Response(
492+
JSON.stringify({
493+
application_id: "app_1",
494+
instances: [
495+
{ instance_id: "ins_dev", environment_type: "development", publishable_key: pk },
496+
],
497+
}),
498+
{ status: 200 },
499+
);
500+
}
501+
fapiUrl = url;
502+
fapiAuth = new Headers(init?.headers).get("Authorization");
503+
return new Response(JSON.stringify({ environment: "ok" }), { status: 200 });
504+
});
505+
506+
await runApi("/environment", { fapi: true, app: "app_1", instance: "dev" });
507+
expect(fapiUrl).toContain("https://clerk.example.com/v1/environment");
508+
expect(fapiUrl).toContain("_clerk_js_version=");
509+
expect(fapiAuth).toBeNull();
510+
});
511+
512+
test("--fapi cannot be combined with --platform", async () => {
513+
await expect(runApi("/environment", { fapi: true, platform: true })).rejects.toThrow(
514+
"cannot be combined",
515+
);
516+
});
517+
518+
test("--fapi prints API error response body to stdout and exits 1", async () => {
519+
setMode("human");
520+
process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform";
521+
const pk = `pk_test_${btoa("clerk.example.com$")}`;
522+
const errorBody = { errors: [{ message: "no environment", code: "not_found" }] };
523+
524+
stubFetch(async (input) => {
525+
const url = input.toString();
526+
if (url.includes("/v1/platform/applications/app_1")) {
527+
return new Response(
528+
JSON.stringify({
529+
application_id: "app_1",
530+
instances: [
531+
{ instance_id: "ins_dev", environment_type: "development", publishable_key: pk },
532+
],
533+
}),
534+
{ status: 200 },
535+
);
536+
}
537+
return new Response(JSON.stringify(errorBody), { status: 404 });
538+
});
539+
540+
await runApi("/environment", { fapi: true, app: "app_1" });
541+
expect(process.exitCode).toBe(1);
542+
expect(captured.out).toContain(JSON.stringify(errorBody, null, 2));
543+
});
544+
479545
// --- Error handling ---
480546

481547
test("errors when no secret key available", async () => {

packages/cli-core/src/commands/api/index.ts

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import type { Program } from "../../cli-program.ts";
22
import { getAuthToken } from "../../lib/plapi.ts";
33
import { getBapiBaseUrl, getPlapiBaseUrl } from "../../lib/environment.ts";
44
import { normalizeBapiPath, resolveBapiSecretKey } from "../../lib/bapi-command.ts";
5-
import { bapiRequest } from "./bapi.ts";
5+
import { bapiRequest, type BapiResponse } from "./bapi.ts";
6+
import { fapiRequest, resolveFapiHost } from "./fapi.ts";
67
import {
7-
BapiError,
8+
ApiError,
89
ERROR_CODE,
910
UserAbortError,
1011
isPromptExitError,
@@ -25,12 +26,43 @@ export interface ApiOptions {
2526
secretKey?: string;
2627
instance?: string;
2728
platform?: boolean;
29+
fapi?: boolean;
2830
dryRun?: boolean;
2931
yes?: boolean;
3032
}
3133

3234
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
3335

36+
type RunRequest = (req: { method: string; path: string; body?: string }) => Promise<BapiResponse>;
37+
38+
/** Resolve the API surface (base URL + request executor) from the flags. */
39+
async function resolveApiTarget(
40+
options: ApiOptions,
41+
): Promise<{ baseUrl: string; runRequest: RunRequest }> {
42+
if (options.fapi) {
43+
if (options.platform) {
44+
throwUsageError(
45+
"--fapi and --platform cannot be combined.",
46+
undefined,
47+
ERROR_CODE.USAGE_ERROR,
48+
);
49+
}
50+
const fapiHost = await resolveFapiHost(options);
51+
const baseUrl = `https://${fapiHost}`;
52+
return { baseUrl, runRequest: (req) => fapiRequest({ ...req, fapiHost }) };
53+
}
54+
55+
if (options.platform) {
56+
const secretKey = await getAuthToken();
57+
const baseUrl = getPlapiBaseUrl();
58+
return { baseUrl, runRequest: (req) => bapiRequest({ ...req, secretKey, baseUrl }) };
59+
}
60+
61+
const secretKey = await resolveBapiSecretKey(options);
62+
const baseUrl = getBapiBaseUrl();
63+
return { baseUrl, runRequest: (req) => bapiRequest({ ...req, secretKey, baseUrl }) };
64+
}
65+
3466
export async function api(
3567
endpoint: string | undefined,
3668
filter: string | undefined,
@@ -61,17 +93,8 @@ export async function api(
6193
// 2. Determine HTTP method
6294
const method = (options.method ?? (body ? "POST" : "GET")).toUpperCase();
6395

64-
// 3. Resolve authentication
65-
let secretKey: string;
66-
let baseUrl: string;
67-
68-
if (options.platform) {
69-
secretKey = await getAuthToken();
70-
baseUrl = getPlapiBaseUrl();
71-
} else {
72-
secretKey = await resolveBapiSecretKey(options);
73-
baseUrl = getBapiBaseUrl();
74-
}
96+
// 3. Resolve the request target (base URL + executor)
97+
const { baseUrl, runRequest } = await resolveApiTarget(options);
7598

7699
// 4. Dry run
77100
if (options.dryRun) {
@@ -97,13 +120,7 @@ export async function api(
97120
// 6. Execute request
98121
try {
99122
const response = await withSpinner("Executing request...", () =>
100-
bapiRequest({
101-
method,
102-
path: endpoint,
103-
secretKey,
104-
body: body ?? undefined,
105-
baseUrl,
106-
}),
123+
runRequest({ method, path: endpoint, body: body ?? undefined }),
107124
);
108125

109126
if (options.include) {
@@ -112,10 +129,10 @@ export async function api(
112129
printBody(response.body);
113130
closeStatus = "success";
114131
} catch (error) {
115-
// Handle BapiError locally to print the raw API response body to stdout
132+
// Handle API errors locally to print the raw response body to stdout
116133
// (for piping), rather than propagating to the global error handler.
117-
if (error instanceof BapiError) {
118-
if (options.include) {
134+
if (error instanceof ApiError) {
135+
if (options.include && error.headers) {
119136
printHeaders(error.status, error.headers);
120137
}
121138
prettyPrint(error.body);
@@ -216,6 +233,10 @@ export function registerApi(program: Program): void {
216233
.option("--secret-key <key>", "Override the secret key")
217234
.option("--instance <id>", "Instance to target (dev, prod, or instance ID)")
218235
.option("--platform", "Use Platform API instead of Backend API")
236+
.option(
237+
"--fapi",
238+
"Use the instance's public Frontend API (no auth; host resolved from the publishable key)",
239+
)
219240
.option("--dry-run", "Show the request without executing it")
220241
.option("--yes", "Skip confirmation for mutating requests")
221242
.setExamples([
@@ -226,6 +247,10 @@ export function registerApi(program: Program): void {
226247
command: 'clerk api /users -d \'{"first_name":"Alice"}\'',
227248
description: "POST with a JSON body",
228249
},
250+
{
251+
command: "clerk api --fapi /environment --app <id> --instance dev",
252+
description: "GET the public FAPI environment payload",
253+
},
229254
])
230255
.action(api);
231256
}

0 commit comments

Comments
 (0)