diff --git a/.changeset/wrangler-turnstile.md b/.changeset/wrangler-turnstile.md new file mode 100644 index 0000000000..102bfea1c8 --- /dev/null +++ b/.changeset/wrangler-turnstile.md @@ -0,0 +1,21 @@ +--- +"wrangler": minor +--- + +Add `wrangler turnstile widget` commands for managing Turnstile widgets + +You can now create, list, inspect, update, and delete Turnstile widgets from the CLI: + +``` +wrangler turnstile widget create --domain example.com --mode managed +wrangler turnstile widget list +wrangler turnstile widget get +wrangler turnstile widget update --name "Renamed" +wrangler turnstile widget delete +``` + +All five subcommands accept `--json` for machine-readable output (`get` prints a formatted view by default; the rest print a short human summary). `--domain` accepts comma-separated values, e.g. `--domain a.com,b.com`. `delete --json` requires `--skip-confirmation`/`-y` to keep output pipeable. + +`create` prints the sitekey, the secret, and the canonical `challenges.cloudflare.com/turnstile/v0/siteverify` endpoint for backend verification. The hint is backend-agnostic; it doesn't assume Workers. The secret is redacted from `list` and `update` output but remains available via `get` for retrieval later. `delete` prompts for confirmation; pass `--skip-confirmation`/`-y` to bypass. + +The OAuth flow now requests the `challenge-widgets.write` scope (the existing Bach-derived scope for Turnstile widget CRUD). Existing OAuth sessions need to run `wrangler login` again to pick it up. API token users need a token with the `Account.Turnstile:Edit` permission. diff --git a/packages/wrangler/src/__tests__/deploy/core.test.ts b/packages/wrangler/src/__tests__/deploy/core.test.ts index d4bde4d77e..08bc42cb16 100644 --- a/packages/wrangler/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler/src/__tests__/deploy/core.test.ts @@ -790,7 +790,7 @@ describe("deploy", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in. Total Upload: xx KiB / gzip: xx KiB Worker Startup Time: 100 ms @@ -923,7 +923,7 @@ describe("deploy", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.staging.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.staging.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in. Total Upload: xx KiB / gzip: xx KiB Worker Startup Time: 100 ms diff --git a/packages/wrangler/src/__tests__/experimental-commands-api.test.ts b/packages/wrangler/src/__tests__/experimental-commands-api.test.ts index d6edf2f69d..ee7b0b4dad 100644 --- a/packages/wrangler/src/__tests__/experimental-commands-api.test.ts +++ b/packages/wrangler/src/__tests__/experimental-commands-api.test.ts @@ -223,6 +223,11 @@ describe("experimental_getWranglerCommands", () => { "wrangler secret put", "wrangler tail", "wrangler triggers deploy", + "wrangler turnstile widget create", + "wrangler turnstile widget delete", + "wrangler turnstile widget get", + "wrangler turnstile widget list", + "wrangler turnstile widget update", "wrangler versions deploy", "wrangler versions list", "wrangler versions secret bulk", diff --git a/packages/wrangler/src/__tests__/index.test.ts b/packages/wrangler/src/__tests__/index.test.ts index 424a739cc6..592bf6dd7e 100644 --- a/packages/wrangler/src/__tests__/index.test.ts +++ b/packages/wrangler/src/__tests__/index.test.ts @@ -95,6 +95,7 @@ describe("wrangler", () => { wrangler cert 🪪 Manage client mTLS certificates and CA certificate chains used for secured connections [open beta] wrangler mtls-certificate 🪪 Manage certificates used for mTLS connections wrangler tunnel 🚇 Manage Cloudflare Tunnels [experimental] + wrangler turnstile 🛡️ Manage Turnstile widgets [alpha] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -196,6 +197,7 @@ describe("wrangler", () => { wrangler cert 🪪 Manage client mTLS certificates and CA certificate chains used for secured connections [open beta] wrangler mtls-certificate 🪪 Manage certificates used for mTLS connections wrangler tunnel 🚇 Manage Cloudflare Tunnels [experimental] + wrangler turnstile 🛡️ Manage Turnstile widgets [alpha] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] diff --git a/packages/wrangler/src/__tests__/profiles.test.ts b/packages/wrangler/src/__tests__/profiles.test.ts index dca2576e0e..3779e968ab 100644 --- a/packages/wrangler/src/__tests__/profiles.test.ts +++ b/packages/wrangler/src/__tests__/profiles.test.ts @@ -217,7 +217,7 @@ describe("Profiles", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in. Profile "client-a" created. Run \`wrangler auth activate client-a\` to use this profile in a directory." @@ -235,7 +235,7 @@ describe("Profiles", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in. Profile "client-a" re-authenticated. Run \`wrangler auth activate client-a\` to use this profile in a directory." diff --git a/packages/wrangler/src/__tests__/turnstile.test.ts b/packages/wrangler/src/__tests__/turnstile.test.ts new file mode 100644 index 0000000000..c8b7f76bd0 --- /dev/null +++ b/packages/wrangler/src/__tests__/turnstile.test.ts @@ -0,0 +1,454 @@ +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { http, HttpResponse } from "msw"; +import { afterEach, describe, it } from "vitest"; +import { endEventLoop } from "./helpers/end-event-loop"; +import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs"; +import { useMockIsTTY } from "./helpers/mock-istty"; +import { createFetchResult, msw } from "./helpers/msw"; +import { runWrangler } from "./helpers/run-wrangler"; +import type { + CreateWidgetBody, + UpdateWidgetBody, + Widget, +} from "../turnstile/client"; + +const widgetFixture: Widget = { + sitekey: "0x4AAAAAAAFakeSitekey1", + secret: "0x4AAAAAAAFakeSecret1", + name: "Example", + domains: ["example.com"], + mode: "managed", + bot_fight_mode: false, + clearance_level: "no_clearance", + ephemeral_id: false, + offlabel: false, + region: "world", + created_on: "2026-06-29T12:00:00.000Z", + modified_on: "2026-06-29T12:00:00.000Z", +}; + +describe("turnstile help", () => { + const std = mockConsoleMethods(); + runInTempDir(); + + it("shows top-level help with widget sub-namespace", async ({ expect }) => { + await runWrangler("turnstile"); + await endEventLoop(); + + expect(std.err).toBe(""); + expect(std.out).toContain("wrangler turnstile widget"); + }); + + it("shows widget sub-namespace help with all CRUD commands", async ({ + expect, + }) => { + await runWrangler("turnstile widget"); + await endEventLoop(); + + expect(std.err).toBe(""); + expect(std.out).toContain("wrangler turnstile widget create"); + expect(std.out).toContain("wrangler turnstile widget delete"); + expect(std.out).toContain("wrangler turnstile widget get"); + expect(std.out).toContain("wrangler turnstile widget list"); + expect(std.out).toContain("wrangler turnstile widget update"); + }); +}); + +describe("turnstile widget commands", () => { + mockAccountId(); + mockApiToken(); + runInTempDir(); + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + afterEach(() => { + clearDialogs(); + }); + + it("creates a widget with required args", async ({ expect }) => { + const reqProm = mockWidgetCreate(); + await runWrangler( + "turnstile widget create Example --domain example.com --domain www.example.com --mode managed" + ); + + await expect(reqProm).resolves.toEqual({ + name: "Example", + domains: ["example.com", "www.example.com"], + mode: "managed", + }); + + expect(std.out).toContain("Created Turnstile widget 'Example'"); + expect(std.out).toContain(widgetFixture.sitekey); + expect(std.out).toContain(widgetFixture.secret); + expect(std.out).toContain( + "challenges.cloudflare.com/turnstile/v0/siteverify" + ); + // Output is backend-agnostic; should not prescribe Workers. + expect(std.out).not.toContain("wrangler secret put"); + expect(std.out).not.toContain("wrangler.jsonc"); + }); + + it("creates a widget with optional fields", async ({ expect }) => { + const reqProm = mockWidgetCreate(); + await runWrangler( + "turnstile widget create Example --domain example.com --mode invisible --clearance-level interactive --bot-fight-mode --region world" + ); + + await expect(reqProm).resolves.toEqual({ + name: "Example", + domains: ["example.com"], + mode: "invisible", + bot_fight_mode: true, + clearance_level: "interactive", + region: "world", + }); + }); + + it("prints widget JSON only when --json is set", async ({ expect }) => { + void mockWidgetCreate(); + await runWrangler( + "turnstile widget create Example --domain example.com --mode managed --json" + ); + + expect(std.out).not.toContain("Created Turnstile widget"); + expect(std.out).toContain('"sitekey":'); + expect(std.out).toContain('"secret":'); + }); + + it("errors when --domain is missing", async ({ expect }) => { + await expect( + runWrangler("turnstile widget create Example --mode managed") + ).rejects.toThrow("Missing required argument: domain"); + }); + + it("errors when --mode is missing", async ({ expect }) => { + await expect( + runWrangler("turnstile widget create Example --domain example.com") + ).rejects.toThrow("Missing required argument: mode"); + }); + + it("splits comma-separated values in --domain", async ({ expect }) => { + const reqProm = mockWidgetCreate(); + await runWrangler( + "turnstile widget create Example --domain example.com,www.example.com --mode managed" + ); + + const body = await reqProm; + expect(body.domains).toEqual(["example.com", "www.example.com"]); + }); + + it("lists widgets as a table with count", async ({ expect }) => { + mockWidgetList([widgetFixture]); + await runWrangler("turnstile widget list"); + + expect(std.out).toContain("Found 1 widget:"); + expect(std.out).toContain(widgetFixture.sitekey); + expect(std.out).toContain(widgetFixture.name); + expect(std.out).toContain("example.com"); + }); + + it("pluralizes count when multiple widgets are present", async ({ + expect, + }) => { + const second: Widget = { + ...widgetFixture, + sitekey: "0x4AAAAAAAFakeSitekey2", + name: "Second", + }; + mockWidgetList([widgetFixture, second]); + await runWrangler("turnstile widget list"); + + expect(std.out).toContain("Found 2 widgets:"); + }); + + it("lists widgets as JSON when --json is set", async ({ expect }) => { + mockWidgetList([widgetFixture]); + await runWrangler("turnstile widget list --json"); + + expect(std.out).toContain('"sitekey":'); + expect(std.out).toContain(widgetFixture.sitekey); + }); + + it("reports an empty list with a friendly message", async ({ expect }) => { + mockWidgetList([]); + await runWrangler("turnstile widget list"); + + expect(std.out).toContain("No Turnstile widgets found"); + }); + + it("paginates through multiple pages of widgets", async ({ expect }) => { + // The Turnstile list endpoint uses the standard V4 paginated shape + // (`result_info.page` + `per_page` + `total_count`). `fetchPagedListResult` + // reads `page` from the query string and keeps requesting until + // `hasMorePages` returns false. This test exercises that iteration + // so a future change to cursor-based pagination would break here + // visibly rather than silently returning only the first page. + const w = (id: string): Widget => ({ + ...widgetFixture, + sitekey: id, + name: id, + }); + const page1 = [w("0x4pg1a"), w("0x4pg1b")]; + const page2 = [w("0x4pg2a"), w("0x4pg2b")]; + + msw.use( + http.get("*/accounts/:accountId/challenges/widgets", ({ request }) => { + const page = Number( + new URL(request.url).searchParams.get("page") ?? "1" + ); + const widgets = page === 1 ? page1 : page2; + return HttpResponse.json( + createFetchResult(widgets, true, [], [], { + page, + per_page: 2, + count: 2, + total_count: 4, + }) + ); + }) + ); + + await runWrangler("turnstile widget list"); + + expect(std.out).toContain("Found 4 widgets:"); + for (const entry of [...page1, ...page2]) { + expect(std.out).toContain(entry.sitekey); + } + }); + + it("gets a single widget in the default human-readable view", async ({ + expect, + }) => { + mockWidgetGet(widgetFixture); + await runWrangler(`turnstile widget get ${widgetFixture.sitekey}`); + + expect(std.out).toContain(`Sitekey`); + expect(std.out).toContain(widgetFixture.sitekey); + expect(std.out).toContain(widgetFixture.name); + expect(std.out).toContain(widgetFixture.secret); + }); + + it("gets a single widget as JSON when --json is set", async ({ expect }) => { + mockWidgetGet(widgetFixture); + await runWrangler(`turnstile widget get ${widgetFixture.sitekey} --json`); + + expect(std.out).toContain(`"sitekey": "${widgetFixture.sitekey}"`); + expect(std.out).toContain(`"name": "${widgetFixture.name}"`); + }); + + it("errors when update is called with no fields", async ({ expect }) => { + await expect( + runWrangler(`turnstile widget update ${widgetFixture.sitekey}`) + ).rejects.toThrow("No fields to update"); + }); + + it("updates a widget by merging changes with the current state", async ({ + expect, + }) => { + mockWidgetGet(widgetFixture); + const reqProm = mockWidgetUpdate(widgetFixture.sitekey); + + await runWrangler( + `turnstile widget update ${widgetFixture.sitekey} --name "Renamed"` + ); + + const body = await reqProm; + // PUT requires the full body. The CLI must preserve domains+mode + // from the GET response when only --name is passed. + expect(body).toEqual({ + name: "Renamed", + domains: widgetFixture.domains, + mode: widgetFixture.mode, + bot_fight_mode: widgetFixture.bot_fight_mode, + clearance_level: widgetFixture.clearance_level, + ephemeral_id: widgetFixture.ephemeral_id, + offlabel: widgetFixture.offlabel, + }); + expect(std.out).toContain( + `Updated Turnstile widget ${widgetFixture.sitekey}` + ); + // PUT response includes the secret, but the CLI strips it from output. + // Users can re-fetch it via `get` if needed. + expect(std.out).not.toContain(widgetFixture.secret); + expect(std.out).not.toContain('"secret":'); + }); + + it("deletes a widget after confirmation", async ({ expect }) => { + setIsTTY(true); + mockConfirm({ + text: `About to delete Turnstile widget ${widgetFixture.sitekey}. This breaks any deployed Worker still using the widget's sitekey or secret. Continue?`, + result: true, + }); + mockWidgetDelete(widgetFixture.sitekey); + + await runWrangler(`turnstile widget delete ${widgetFixture.sitekey}`); + + expect(std.out).toContain( + `Deleted Turnstile widget ${widgetFixture.sitekey}` + ); + }); + + it("cancels delete if confirmation is declined", async ({ expect }) => { + setIsTTY(true); + mockConfirm({ + text: `About to delete Turnstile widget ${widgetFixture.sitekey}. This breaks any deployed Worker still using the widget's sitekey or secret. Continue?`, + result: false, + }); + + await runWrangler(`turnstile widget delete ${widgetFixture.sitekey}`); + + expect(std.out).toContain("Deletion cancelled."); + expect(std.out).not.toContain("Deleted Turnstile widget"); + }); + + it("skips confirmation with --skip-confirmation", async ({ expect }) => { + mockWidgetDelete(widgetFixture.sitekey); + await runWrangler( + `turnstile widget delete ${widgetFixture.sitekey} --skip-confirmation` + ); + + expect(std.out).toContain( + `Deleted Turnstile widget ${widgetFixture.sitekey}` + ); + }); + + it("skips confirmation with -y alias", async ({ expect }) => { + mockWidgetDelete(widgetFixture.sitekey); + await runWrangler(`turnstile widget delete ${widgetFixture.sitekey} -y`); + + expect(std.out).toContain( + `Deleted Turnstile widget ${widgetFixture.sitekey}` + ); + }); + + it("outputs JSON when --json is set with --skip-confirmation", async ({ + expect, + }) => { + mockWidgetDelete(widgetFixture.sitekey); + await runWrangler( + `turnstile widget delete ${widgetFixture.sitekey} --skip-confirmation --json` + ); + + expect(JSON.parse(std.out)).toEqual({ + sitekey: widgetFixture.sitekey, + success: true, + }); + }); + + it("errors when --json is set without --skip-confirmation", async ({ + expect, + }) => { + await expect( + runWrangler(`turnstile widget delete ${widgetFixture.sitekey} --json`) + ).rejects.toThrow(/skip-confirmation/); + }); +}); + +function mockWidgetCreate(): Promise { + return new Promise((resolve, reject) => { + msw.use( + http.post( + "*/accounts/:accountId/challenges/widgets", + async ({ request }) => { + // Turnstile's API rejects anything other than application/json. + // fetchResult defaults to text/plain for string bodies, so the + // client must set this header explicitly. + const ct = request.headers.get("content-type") ?? ""; + if (!ct.includes("application/json")) { + reject(new Error(`expected application/json, got '${ct}'`)); + return HttpResponse.json( + { error: "wrong content-type" }, + { status: 415 } + ); + } + const body = (await request.json()) as CreateWidgetBody; + // Strip undefined optional fields so equality assertions + // only see what the CLI actually sent. + const cleaned = JSON.parse(JSON.stringify(body)) as CreateWidgetBody; + resolve(cleaned); + return HttpResponse.json( + createFetchResult( + { + ...widgetFixture, + name: cleaned.name, + domains: cleaned.domains, + mode: cleaned.mode, + bot_fight_mode: + cleaned.bot_fight_mode ?? widgetFixture.bot_fight_mode, + clearance_level: + cleaned.clearance_level ?? widgetFixture.clearance_level, + ephemeral_id: + cleaned.ephemeral_id ?? widgetFixture.ephemeral_id, + offlabel: cleaned.offlabel ?? widgetFixture.offlabel, + region: cleaned.region ?? widgetFixture.region, + }, + true + ) + ); + }, + { once: true } + ) + ); + }); +} + +function mockWidgetList(widgets: Widget[]): void { + msw.use( + http.get( + "*/accounts/:accountId/challenges/widgets", + () => + HttpResponse.json(createFetchResult(widgets, true), { + headers: { "Content-Type": "application/json" }, + }), + { once: true } + ) + ); +} + +function mockWidgetGet(widget: Widget): void { + msw.use( + http.get( + `*/accounts/:accountId/challenges/widgets/${widget.sitekey}`, + () => HttpResponse.json(createFetchResult(widget, true)), + { once: true } + ) + ); +} + +function mockWidgetUpdate(sitekey: string): Promise { + return new Promise((resolve, reject) => { + msw.use( + http.put( + `*/accounts/:accountId/challenges/widgets/${sitekey}`, + async ({ request }) => { + const ct = request.headers.get("content-type") ?? ""; + if (!ct.includes("application/json")) { + reject(new Error(`expected application/json, got '${ct}'`)); + return HttpResponse.json( + { error: "wrong content-type" }, + { status: 415 } + ); + } + const body = (await request.json()) as UpdateWidgetBody; + resolve(body); + return HttpResponse.json( + createFetchResult({ ...widgetFixture, ...body }, true) + ); + }, + { once: true } + ) + ); + }); +} + +function mockWidgetDelete(sitekey: string): void { + msw.use( + http.delete( + `*/accounts/:accountId/challenges/widgets/${sitekey}`, + () => HttpResponse.json(createFetchResult(null, true)), + { once: true } + ) + ); +} diff --git a/packages/wrangler/src/__tests__/user.test.ts b/packages/wrangler/src/__tests__/user.test.ts index d4539c4ce3..969f7277b8 100644 --- a/packages/wrangler/src/__tests__/user.test.ts +++ b/packages/wrangler/src/__tests__/user.test.ts @@ -105,7 +105,7 @@ describe("User", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(readAuthCredentials()).toEqual({ @@ -193,7 +193,7 @@ describe("User", () => { Temporary login server listening on 0.0.0.0:8976 Note that the OAuth login page will always redirect to \`http://localhost:8976/oauth/callback\`. If you have changed the callback host or port because you are running in a container, then ensure that you have port forwarding set up correctly. - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(readAuthCredentials()).toEqual({ @@ -239,7 +239,7 @@ describe("User", () => { Temporary login server listening on mylocalhost.local:8976 Note that the OAuth login page will always redirect to \`http://localhost:8976/oauth/callback\`. If you have changed the callback host or port because you are running in a container, then ensure that you have port forwarding set up correctly. - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(readAuthCredentials()).toEqual({ @@ -285,7 +285,7 @@ describe("User", () => { Temporary login server listening on localhost:8787 Note that the OAuth login page will always redirect to \`http://localhost:8976/oauth/callback\`. If you have changed the callback host or port because you are running in a container, then ensure that you have port forwarding set up correctly. - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(readAuthCredentials()).toEqual({ @@ -327,7 +327,7 @@ describe("User", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.staging.cloudflare.com/oauth2/auth?response_type=code&client_id=4b2ea6cc-9421-4761-874b-ce550e0e3def&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.staging.cloudflare.com/oauth2/auth?response_type=code&client_id=4b2ea6cc-9421-4761-874b-ce550e0e3def&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); @@ -1297,7 +1297,7 @@ describe("User", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20websearch.run%20agent-memory%3Awrite%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20artifacts%3Awrite%20flagship%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20browser%3Awrite%20challenge-widgets.write%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(std.warn).toMatchInlineSnapshot(`""`); diff --git a/packages/wrangler/src/__tests__/whoami.test.ts b/packages/wrangler/src/__tests__/whoami.test.ts index 42a596db79..ac641994ba 100644 --- a/packages/wrangler/src/__tests__/whoami.test.ts +++ b/packages/wrangler/src/__tests__/whoami.test.ts @@ -362,6 +362,7 @@ describe("whoami", () => { - email_routing:write - email_sending:write - browser:write + - challenge-widgets.write 🎢 Membership roles in "Account Two": Contact account super admin to change your permissions. @@ -439,6 +440,7 @@ describe("whoami", () => { - email_routing:write - email_sending:write - browser:write + - challenge-widgets.write 🎢 Membership roles in "Account Two": Contact account super admin to change your permissions. @@ -570,6 +572,7 @@ describe("whoami", () => { - email_routing:write - email_sending:write - browser:write + - challenge-widgets.write 🎢 Unable to get membership roles. Make sure you have permissions to read the account. Are you missing the \`User->Memberships->Read\` permission?" diff --git a/packages/wrangler/src/core/teams.d.ts b/packages/wrangler/src/core/teams.d.ts index 9a1b5c7ef3..8acc928c07 100644 --- a/packages/wrangler/src/core/teams.d.ts +++ b/packages/wrangler/src/core/teams.d.ts @@ -28,4 +28,5 @@ export type Teams = | "Product: Email Service" | "Product: Browser Run" | "Product: Artifacts" - | "Product: Flagship"; + | "Product: Flagship" + | "Product: Turnstile"; diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 79b635bbe1..7b4bf1400a 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -467,6 +467,15 @@ import { tunnelInfoCommand } from "./tunnel/info"; import { tunnelListCommand } from "./tunnel/list"; import { tunnelQuickStartCommand } from "./tunnel/quick-start"; import { tunnelRunCommand } from "./tunnel/run"; +import { turnstileWidgetCreateCommand } from "./turnstile/create"; +import { turnstileWidgetDeleteCommand } from "./turnstile/delete"; +import { turnstileWidgetGetCommand } from "./turnstile/get"; +import { + turnstileNamespace, + turnstileWidgetNamespace, +} from "./turnstile/index"; +import { turnstileWidgetListCommand } from "./turnstile/list"; +import { turnstileWidgetUpdateCommand } from "./turnstile/update"; import { typesCommand } from "./type-generation"; import { authKeyringCommand, @@ -1676,6 +1685,36 @@ export function createCLIParser(argv: string[]) { ]); registry.registerNamespace("flagship"); + // turnstile + registry.define([ + { command: "wrangler turnstile", definition: turnstileNamespace }, + { + command: "wrangler turnstile widget", + definition: turnstileWidgetNamespace, + }, + { + command: "wrangler turnstile widget create", + definition: turnstileWidgetCreateCommand, + }, + { + command: "wrangler turnstile widget delete", + definition: turnstileWidgetDeleteCommand, + }, + { + command: "wrangler turnstile widget get", + definition: turnstileWidgetGetCommand, + }, + { + command: "wrangler turnstile widget list", + definition: turnstileWidgetListCommand, + }, + { + command: "wrangler turnstile widget update", + definition: turnstileWidgetUpdateCommand, + }, + ]); + registry.registerNamespace("turnstile"); + // tunnel registry.define([ { command: "wrangler tunnel", definition: tunnelNamespace }, diff --git a/packages/wrangler/src/turnstile/client.ts b/packages/wrangler/src/turnstile/client.ts new file mode 100644 index 0000000000..ad484b50ab --- /dev/null +++ b/packages/wrangler/src/turnstile/client.ts @@ -0,0 +1,127 @@ +import { fetchPagedListResult, fetchResult } from "../cfetch"; +import { requireAuth } from "../user"; +import type { Config } from "@cloudflare/workers-utils"; + +export const WidgetModes = ["managed", "invisible", "non-interactive"] as const; +export type WidgetMode = (typeof WidgetModes)[number]; + +export const ClearanceLevels = [ + "no_clearance", + "jschallenge", + "managed", + "interactive", +] as const; +export type ClearanceLevel = (typeof ClearanceLevels)[number]; + +export const WidgetRegions = ["world", "china"] as const; +export type WidgetRegion = (typeof WidgetRegions)[number]; + +export type Widget = { + sitekey: string; + secret: string; + name: string; + domains: string[]; + mode: WidgetMode; + bot_fight_mode: boolean; + clearance_level: ClearanceLevel; + ephemeral_id: boolean; + offlabel: boolean; + region: WidgetRegion; + created_on: string; + modified_on: string; +}; + +export type CreateWidgetBody = { + name: string; + domains: string[]; + mode: WidgetMode; + bot_fight_mode?: boolean; + clearance_level?: ClearanceLevel; + ephemeral_id?: boolean; + offlabel?: boolean; + region?: WidgetRegion; +}; + +export type UpdateWidgetBody = { + name: string; + domains: string[]; + mode: WidgetMode; + bot_fight_mode?: boolean; + clearance_level?: ClearanceLevel; + ephemeral_id?: boolean; + offlabel?: boolean; +}; + +const JSON_HEADERS = { "Content-Type": "application/json" }; + +export async function createWidget( + config: Config, + body: CreateWidgetBody +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/challenges/widgets`, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(body), + } + ); +} + +export async function getWidget( + config: Config, + sitekey: string +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/challenges/widgets/${sitekey}`, + { + method: "GET", + } + ); +} + +export async function listWidgets(config: Config): Promise { + const accountId = await requireAuth(config); + return await fetchPagedListResult( + config, + `/accounts/${accountId}/challenges/widgets`, + { + method: "GET", + } + ); +} + +export async function updateWidget( + config: Config, + sitekey: string, + body: UpdateWidgetBody +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/challenges/widgets/${sitekey}`, + { + method: "PUT", + headers: JSON_HEADERS, + body: JSON.stringify(body), + } + ); +} + +export async function deleteWidget( + config: Config, + sitekey: string +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/challenges/widgets/${sitekey}`, + { + method: "DELETE", + } + ); +} diff --git a/packages/wrangler/src/turnstile/create.ts b/packages/wrangler/src/turnstile/create.ts new file mode 100644 index 0000000000..cbda0f1390 --- /dev/null +++ b/packages/wrangler/src/turnstile/create.ts @@ -0,0 +1,70 @@ +import { createCommand } from "../core/create-command"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; +import { createWidget, WidgetRegions } from "./client"; +import { sharedWidgetOptions } from "."; + +export const turnstileWidgetCreateCommand = createCommand({ + metadata: { + description: "Create a Turnstile widget", + status: "alpha", + owner: "Product: Turnstile", + }, + behaviour: { + supportTemporary: true, + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, + args: { + name: { + type: "string", + demandOption: true, + description: "Human-readable widget name", + }, + ...sharedWidgetOptions({ required: true }), + region: { + type: "string", + choices: WidgetRegions, + description: + "Region where this widget can be used. Cannot be changed after creation.", + }, + json: { + type: "boolean", + default: false, + description: "Print the created widget as JSON only", + }, + }, + positionalArgs: ["name"], + async handler(args, { config }) { + const widget = await createWidget(config, { + name: args.name, + domains: args.domain, + mode: args.mode, + bot_fight_mode: args.botFightMode, + clearance_level: args.clearanceLevel, + ephemeral_id: args.ephemeralId, + offlabel: args.offlabel, + region: args.region, + }); + + if (args.json) { + logger.log(JSON.stringify(widget, null, 2)); + return; + } + + logger.log(`✅ Created Turnstile widget '${widget.name}'`); + logger.log(` sitekey: ${widget.sitekey}`); + logger.log(` secret: ${widget.secret}`); + logger.log(""); + logger.log( + "The sitekey is public; embed it in your frontend. The secret must stay on your backend and is used for siteverify:" + ); + logger.log( + " POST https://challenges.cloudflare.com/turnstile/v0/siteverify" + ); + logger.log(' { secret: "", response: "" }'); + logger.log(""); + logger.log( + `To retrieve the secret later: \`wrangler turnstile widget get ${widget.sitekey}\`. It's redacted from \`list\` and \`update\` output but always available via \`get\`.` + ); + }, +}); diff --git a/packages/wrangler/src/turnstile/delete.ts b/packages/wrangler/src/turnstile/delete.ts new file mode 100644 index 0000000000..639371e025 --- /dev/null +++ b/packages/wrangler/src/turnstile/delete.ts @@ -0,0 +1,65 @@ +import { JsonFriendlyFatalError } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; +import { confirm } from "../dialogs"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; +import { deleteWidget } from "./client"; + +export const turnstileWidgetDeleteCommand = createCommand({ + metadata: { + description: "Delete a Turnstile widget", + status: "alpha", + owner: "Product: Turnstile", + }, + behaviour: { + supportTemporary: true, + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, + args: { + sitekey: { + type: "string", + demandOption: true, + description: "The sitekey of the widget", + }, + "skip-confirmation": { + type: "boolean", + alias: "y", + default: false, + description: "Skip confirmation prompt", + }, + json: { + type: "boolean", + default: false, + description: "Return output as JSON. Requires --skip-confirmation.", + }, + }, + positionalArgs: ["sitekey"], + async handler({ sitekey, skipConfirmation, json }, { config }) { + if (json && !skipConfirmation) { + throw new JsonFriendlyFatalError( + JSON.stringify({ + error: + "Pass --skip-confirmation (-y) to skip the confirmation prompt when using --json.", + }), + { telemetryMessage: "turnstile widget delete json requires skip" } + ); + } + if (!skipConfirmation) { + const proceed = await confirm( + `About to delete Turnstile widget ${sitekey}. This breaks any deployed Worker still using the widget's sitekey or secret. Continue?`, + { defaultValue: false, fallbackValue: false } + ); + if (!proceed) { + logger.log("Deletion cancelled."); + return; + } + } + + await deleteWidget(config, sitekey); + if (json) { + logger.log(JSON.stringify({ sitekey, success: true }, null, 2)); + return; + } + logger.log(`✅ Deleted Turnstile widget ${sitekey}`); + }, +}); diff --git a/packages/wrangler/src/turnstile/get.ts b/packages/wrangler/src/turnstile/get.ts new file mode 100644 index 0000000000..013ef362b0 --- /dev/null +++ b/packages/wrangler/src/turnstile/get.ts @@ -0,0 +1,56 @@ +import { createCommand } from "../core/create-command"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; +import { getWidget, type Widget } from "./client"; + +function formatWidget(widget: Widget): string { + const rows: [string, string][] = [ + ["Sitekey", widget.sitekey], + ["Name", widget.name], + ["Mode", widget.mode], + ["Domains", widget.domains.join(", ")], + ["Clearance level", widget.clearance_level], + ["Bot fight mode", widget.bot_fight_mode ? "yes" : "no"], + ["Region", widget.region], + ["Off-label", widget.offlabel ? "yes" : "no"], + ["Ephemeral ID", widget.ephemeral_id ? "yes" : "no"], + ["Secret", widget.secret], + ["Created", widget.created_on], + ["Modified", widget.modified_on], + ]; + const widest = Math.max(...rows.map(([k]) => k.length)); + return rows.map(([k, v]) => ` ${k.padEnd(widest)} ${v}`).join("\n"); +} + +export const turnstileWidgetGetCommand = createCommand({ + metadata: { + description: "Get a Turnstile widget", + status: "alpha", + owner: "Product: Turnstile", + }, + behaviour: { + supportTemporary: true, + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, + args: { + sitekey: { + type: "string", + demandOption: true, + description: "The sitekey of the widget", + }, + json: { + type: "boolean", + default: false, + description: "Print the widget as JSON instead of a formatted view", + }, + }, + positionalArgs: ["sitekey"], + async handler(args, { config }) { + const widget = await getWidget(config, args.sitekey); + if (args.json) { + logger.log(JSON.stringify(widget, null, 2)); + return; + } + logger.log(formatWidget(widget)); + }, +}); diff --git a/packages/wrangler/src/turnstile/index.ts b/packages/wrangler/src/turnstile/index.ts new file mode 100644 index 0000000000..575bbdd470 --- /dev/null +++ b/packages/wrangler/src/turnstile/index.ts @@ -0,0 +1,73 @@ +import { createNamespace } from "../core/create-command"; +import { ClearanceLevels, WidgetModes } from "./client"; + +export const turnstileNamespace = createNamespace({ + metadata: { + description: "🛡️ Manage Turnstile widgets", + status: "alpha", + owner: "Product: Turnstile", + category: "Networking & security", + }, +}); + +export const turnstileWidgetNamespace = createNamespace({ + metadata: { + description: "Manage Turnstile widgets", + status: "alpha", + owner: "Product: Turnstile", + }, +}); + +// Splits comma-separated values so `--domain a.com,b.com` is equivalent to +// `--domain a.com --domain b.com`. Yargs `array: true` alone preserves the +// comma as a literal character. +const splitCsv = (values: (string | number)[]): string[] => + values + .flatMap((v) => + String(v) + .split(",") + .map((s) => s.trim()) + ) + .filter(Boolean); + +export const sharedWidgetOptions = (options: { + required: R; +}) => + ({ + domain: { + alias: "domains", + type: "string", + array: true, + demandOption: options.required, + coerce: splitCsv, + description: + "Allowed hostnames for this widget. Pass multiple times or comma-separate, e.g. --domain example.com --domain www.example.com.", + }, + mode: { + type: "string", + choices: WidgetModes, + demandOption: options.required, + description: "Widget mode", + }, + "clearance-level": { + type: "string", + choices: ClearanceLevels, + description: + "Challenge clearance level granted when this widget is embedded on a Cloudflare site", + }, + "bot-fight-mode": { + type: "boolean", + description: + "Issue computationally expensive challenges in response to malicious bots (Enterprise only)", + }, + "ephemeral-id": { + type: "boolean", + description: + "Return the Ephemeral ID in /siteverify responses (Enterprise only)", + }, + offlabel: { + type: "boolean", + description: + "Do not show any Cloudflare branding on the widget (Enterprise only)", + }, + }) as const; diff --git a/packages/wrangler/src/turnstile/list.ts b/packages/wrangler/src/turnstile/list.ts new file mode 100644 index 0000000000..1f98aa9a7d --- /dev/null +++ b/packages/wrangler/src/turnstile/list.ts @@ -0,0 +1,53 @@ +import { createCommand } from "../core/create-command"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; +import { listWidgets, type Widget } from "./client"; + +function widgetRowForTable(widget: Widget) { + return { + sitekey: widget.sitekey, + name: widget.name, + mode: widget.mode, + domains: widget.domains.join(", "), + clearance_level: widget.clearance_level, + bot_fight_mode: widget.bot_fight_mode ? "yes" : "no", + region: widget.region, + created_on: widget.created_on, + }; +} + +export const turnstileWidgetListCommand = createCommand({ + metadata: { + description: "List Turnstile widgets", + status: "alpha", + owner: "Product: Turnstile", + }, + behaviour: { + supportTemporary: true, + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, + args: { + json: { + type: "boolean", + default: false, + description: "Print widgets as JSON instead of a table", + }, + }, + async handler(args, { config }) { + const widgets = await listWidgets(config); + + if (args.json) { + logger.log(JSON.stringify(widgets, null, 2)); + return; + } + + if (widgets.length === 0) { + logger.log("No Turnstile widgets found."); + return; + } + + const label = widgets.length === 1 ? "widget" : "widgets"; + logger.log(`Found ${widgets.length} ${label}:`); + logger.table(widgets.map(widgetRowForTable)); + }, +}); diff --git a/packages/wrangler/src/turnstile/update.ts b/packages/wrangler/src/turnstile/update.ts new file mode 100644 index 0000000000..8fb1969a13 --- /dev/null +++ b/packages/wrangler/src/turnstile/update.ts @@ -0,0 +1,80 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; +import { getWidget, updateWidget } from "./client"; +import { sharedWidgetOptions } from "."; + +export const turnstileWidgetUpdateCommand = createCommand({ + metadata: { + description: "Update a Turnstile widget", + status: "alpha", + owner: "Product: Turnstile", + }, + behaviour: { + supportTemporary: true, + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, + args: { + sitekey: { + type: "string", + demandOption: true, + description: "The sitekey of the widget", + }, + name: { + type: "string", + description: "New human-readable name for the widget", + }, + ...sharedWidgetOptions({ required: false }), + json: { + type: "boolean", + default: false, + description: "Print the updated widget as JSON only", + }, + }, + positionalArgs: ["sitekey"], + async handler(args, { config }) { + const hasChanges = + args.name !== undefined || + args.domain !== undefined || + args.mode !== undefined || + args.botFightMode !== undefined || + args.clearanceLevel !== undefined || + args.ephemeralId !== undefined || + args.offlabel !== undefined; + if (!hasChanges) { + throw new UserError( + "No fields to update. Pass at least one of --name, --domain, --mode, --clearance-level, --bot-fight-mode, --ephemeral-id, --offlabel.", + { telemetryMessage: "turnstile widget update no fields" } + ); + } + + // Turnstile PUT requires the full body (name, domains, mode). Fetch + // the current widget so callers can change one field without losing + // the rest. + const current = await getWidget(config, args.sitekey); + + const updated = await updateWidget(config, args.sitekey, { + name: args.name ?? current.name, + domains: args.domain ?? current.domains, + mode: args.mode ?? current.mode, + bot_fight_mode: args.botFightMode ?? current.bot_fight_mode, + clearance_level: args.clearanceLevel ?? current.clearance_level, + ephemeral_id: args.ephemeralId ?? current.ephemeral_id, + offlabel: args.offlabel ?? current.offlabel, + }); + + // Strip secret from output. The PUT response includes it, but `update` + // is for config changes, not secret retrieval. Use `get` if you need + // to see the secret. + const { secret: _secret, ...withoutSecret } = updated; + + if (args.json) { + logger.log(JSON.stringify(withoutSecret, null, 2)); + return; + } + + logger.log(`✅ Updated Turnstile widget ${updated.sitekey}`); + logger.log(JSON.stringify(withoutSecret, null, 2)); + }, +}); diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index 60de5acaae..c4de8039e7 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -252,6 +252,7 @@ const DefaultScopes = { "email_sending:write": "See and change Email Sending settings and configuration.", "browser:write": "See and manage Browser Run sessions", + "challenge-widgets.write": "See and change Turnstile widgets", } as const; /**