Skip to content
Merged
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/api-fapi-passthrough.md
Original file line number Diff line number Diff line change
@@ -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 <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.
39 changes: 27 additions & 12 deletions packages/cli-core/src/commands/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <method>` | HTTP method. Defaults to GET, or POST if body is provided. |
| `-d, --data <json>` | JSON request body (inline) |
| `--file <path>` | Read request body from a file |
| `--include` | Show response status and headers |
| `--app <id>` | Application ID to target when resolving keys |
| `--secret-key <key>` | Override the secret key |
| `--instance <id>` | 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 <method>` | HTTP method. Defaults to GET, or POST if body is provided. |
| `-d, --data <json>` | JSON request body (inline) |
| `--file <path>` | Read request body from a file |
| `--include` | Show response status and headers |
| `--app <id>` | Application ID to target when resolving keys |
| `--secret-key <key>` | Override the secret key |
| `--instance <id>` | 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

Expand All @@ -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)
Expand Down
11 changes: 2 additions & 9 deletions packages/cli-core/src/commands/api/bapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,15 @@
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;
path: string;
secretKey: string;
body?: string;
baseUrl?: string;
}): Promise<BapiResponse> {
}): Promise<ApiResponse> {
const base = options.baseUrl ?? getBapiBaseUrl();
const path = normalizeBapiPath(options.path);

Expand Down
61 changes: 61 additions & 0 deletions packages/cli-core/src/commands/api/fapi.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Comment thread
rafa-thayto marked this conversation as resolved.
async function resolveInstance(options: ResolveOptions): Promise<ApplicationInstance> {
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<ReturnType<typeof resolveAppContext>>;
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 <app_id>.",
"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<string> {
const instance = await resolveInstance(options);
return decodePublishableKey(instance.publishable_key).fapiHost;
}
137 changes: 137 additions & 0 deletions packages/cli-core/src/commands/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,143 @@ describe("api command", () => {
);
});

// --- --fapi mode ---
Comment thread
rafa-thayto marked this conversation as resolved.

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 <fapi-host>");
});

// --- Error handling ---

test("errors when no secret key available", async () => {
Expand Down
Loading