Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 4
feat(api): add clerk api --fapi for the public Frontend API#345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
020a2f5
feat(api): add --fapi to call the public Frontend API
rafa-thayto 64f6f75
fix(api): address review feedback on --fapi passthrough
rafa-thayto e1b5db3
fix(api): address remaining review feedback on --fapi passthrough
rafa-thayto efa6d41
refactor(api): move fapiRequest into lib/fapi.ts
rafa-thayto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "clerk": minor | ||
| --- | ||
| Add `clerk api --fapi` to call an instance's public Frontend API (e.g. `clerk api --fapi /environment --app <id>`). The FAPI host is resolved from the instance's publishable key, and the request is unauthenticated since these endpoints are public, which closes the loop on verifying config changes end to end with the CLI alone. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| /** | ||
| * Instance + FAPI host resolution for `clerk api --fapi`. | ||
| * | ||
| * FAPI is the public API that clerk-js consumes. Its host is per-instance and | ||
| * derived from the instance's publishable key. The passthrough request itself | ||
| * lives in `lib/fapi.ts` (`fapiRequest`) alongside the other FAPI helpers. | ||
| */ | ||
| import { resolveAppContext, resolveFetchedApplicationInstance } from "../../lib/config.ts"; | ||
| import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; | ||
| import { decodePublishableKey } from "../../lib/fapi.ts"; | ||
| import { fetchApplication, type ApplicationInstance } from "../../lib/plapi.ts"; | ||
| interface ResolveOptions { | ||
| app?: string; | ||
| instance?: string; | ||
| } | ||
| 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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -476,6 +476,143 @@ describe("api command", () => { | ||
| ); | ||
| }); | ||
| // --- --fapi mode --- | ||
rafa-thayto marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 () => { | ||
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.