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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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