From 27a783b01371d6ed2c30144151d70fb384fca505 Mon Sep 17 00:00:00 2001 From: Jules Lemee Date: Wed, 1 Jul 2026 10:55:33 -0500 Subject: [PATCH 1/5] [wrangler] Add `turnstile widget` CLI commands Adds `wrangler turnstile widget create | list | get | update | delete` so developers and AI coding agents can provision Cloudflare Turnstile widgets from the CLI instead of dashboard-only. Modelled on the hyperdrive command structure (createNamespace + createCommand, single-purpose files per subcommand, cfetch client layer). Requests the existing `challenge-widgets.write` OAuth scope during `wrangler login`; the scope was added to wrangler's prod OAuth client allowlist via cloudflare/iam/terraform-internal-oauth-clients !137 (already applied). Behaviour notes: - `create` prints sitekey + secret + a backend-agnostic siteverify pointer (does not prescribe Workers as the backend). - `update` does GET-then-merge-then-PUT since Turnstile's PUT requires the full body; strips `secret` from output to avoid an incidental leak when the user only intended to rename. - `delete` prompts for confirmation (`--skip-confirmation`/`-y` bypasses); `fallbackValue: false` in non-interactive contexts. - `create`/`list`/`update` accept `--json`; `get` is always JSON. Banner is suppressed with `--json` and in CI so output is pipeable. - `--domain` accepts comma-separated values in addition to repeated flags. - No new Cloudflare TypeScript SDK dependency (matches the hyperdrive pattern in the same repo); the surface can be migrated later. 19 unit tests via msw. Live-verified end to end against the API using both an API token and the OAuth flow with the newly-allowlisted scope. Companion changes (out of scope here): - cloudflare/skills#73 - canonical SKILL.md rewrite for Turnstile Spin - cloudflare/cloudflare-docs#31517 - public docs page - stratus !39261 - dashboard Spin Lite dialog Bach and OAuth-client sides: - `challenge-widgets.write` OAuth scope was already registered in bach (derived from the `challenge_widgets_write` api_tokens role); no bach change was needed. - cloudflare/iam/terraform-internal-oauth-clients !137 added `challenge-widgets.write` to the wrangler prod client's scopes list (applied + merged). --- .changeset/wrangler-turnstile.md | 21 + .../src/__tests__/deploy/core.test.ts | 4 +- .../experimental-commands-api.test.ts | 5 + packages/wrangler/src/__tests__/index.test.ts | 2 + .../wrangler/src/__tests__/profiles.test.ts | 4 +- .../wrangler/src/__tests__/turnstile.test.ts | 445 ++++++++++++++++++ packages/wrangler/src/__tests__/user.test.ts | 12 +- .../wrangler/src/__tests__/whoami.test.ts | 3 + packages/wrangler/src/core/teams.d.ts | 3 +- packages/wrangler/src/index.ts | 39 ++ packages/wrangler/src/turnstile/client.ts | 127 +++++ packages/wrangler/src/turnstile/create.ts | 80 ++++ packages/wrangler/src/turnstile/delete.ts | 51 ++ packages/wrangler/src/turnstile/get.ts | 56 +++ packages/wrangler/src/turnstile/index.ts | 73 +++ packages/wrangler/src/turnstile/list.ts | 53 +++ packages/wrangler/src/turnstile/update.ts | 80 ++++ packages/wrangler/src/user/user.ts | 1 + 18 files changed, 1048 insertions(+), 11 deletions(-) create mode 100644 .changeset/wrangler-turnstile.md create mode 100644 packages/wrangler/src/__tests__/turnstile.test.ts create mode 100644 packages/wrangler/src/turnstile/client.ts create mode 100644 packages/wrangler/src/turnstile/create.ts create mode 100644 packages/wrangler/src/turnstile/delete.ts create mode 100644 packages/wrangler/src/turnstile/get.ts create mode 100644 packages/wrangler/src/turnstile/index.ts create mode 100644 packages/wrangler/src/turnstile/list.ts create mode 100644 packages/wrangler/src/turnstile/update.ts diff --git a/.changeset/wrangler-turnstile.md b/.changeset/wrangler-turnstile.md new file mode 100644 index 0000000000..516c61a877 --- /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 +``` + +`create`, `list`, and `update` accept `--json` for clean machine-readable output. `--domain` accepts comma-separated values, e.g. `--domain a.com,b.com`. + +`create` prints the sitekey, the secret (shown only at creation time), and the canonical `challenges.cloudflare.com/turnstile/v0/siteverify` endpoint for backend verification. The hint is backend-agnostic; it doesn't assume Workers. `update` strips the secret from its response since it's only incidentally returned. `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 dc83528a53..ea65968a9b 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..04e6e41a83 --- /dev/null +++ b/packages/wrangler/src/__tests__/turnstile.test.ts @@ -0,0 +1,445 @@ +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" + ); + + // The `(Wrangler)` suffix is appended to the widget name at creation + // time so wrangler-created widgets are attributable in analytics. + // Matches the shape Spin uses for its dashboard-created widgets. + await expect(reqProm).resolves.toEqual({ + name: "Example (Wrangler)", + domains: ["example.com", "www.example.com"], + mode: "managed", + }); + + expect(std.out).toContain("Created Turnstile widget 'Example (Wrangler)'"); + 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("doesn't double-append the attribution suffix", async ({ expect }) => { + const reqProm = mockWidgetCreate(); + await runWrangler( + "turnstile widget create 'MyWidget (Wrangler)' --domain example.com --mode managed" + ); + + const body = await reqProm; + expect(body.name).toBe("MyWidget (Wrangler)"); + }); + + 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 (Wrangler)", + 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}` + ); + }); +}); + +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..c0dbbe3cde --- /dev/null +++ b/packages/wrangler/src/turnstile/create.ts @@ -0,0 +1,80 @@ +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 }) { + // Append a `(Wrangler)` suffix to the widget name so wrangler-created + // widgets are attributable in the dashboard's widget list and in the + // Turnstile analytics UI (parallel to how the dashboard's Spin flow + // tags widgets with `(Spin)`). The regex matches trailing suffixes + // with optional whitespace to avoid double-appending if a caller + // deliberately included the marker themselves. + const attributed = /\s\(Wrangler\)\s*$/.test(args.name) + ? args.name + : `${args.name.trim()} (Wrangler)`; + + const widget = await createWidget(config, { + name: attributed, + 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..76c837ab37 --- /dev/null +++ b/packages/wrangler/src/turnstile/delete.ts @@ -0,0 +1,51 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; +import { confirm } from "../dialogs"; +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 }, + args: { + sitekey: { + type: "string", + demandOption: true, + description: "The sitekey of the widget", + }, + "skip-confirmation": { + type: "boolean", + alias: "y", + default: false, + description: "Skip confirmation prompt", + }, + }, + positionalArgs: ["sitekey"], + async handler({ sitekey, skipConfirmation }, { config }) { + 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; + } + } + + try { + await deleteWidget(config, sitekey); + } catch (err) { + const cause = err instanceof Error ? err.message : String(err); + throw new UserError( + `Failed to delete Turnstile widget ${sitekey}: ${cause}`, + { telemetryMessage: "turnstile widget delete failed" } + ); + } + 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; /** From 109010197d4aa47cd634a589be70eff281ddcb18 Mon Sep 17 00:00:00 2001 From: Jules Lemee Date: Tue, 7 Jul 2026 00:53:27 -0500 Subject: [PATCH 2/5] [wrangler] Drop `(Wrangler)` name suffix from turnstile widget create The backend now derives attribution from the request's User-Agent header (`wrangler/`) and stores it on the widget row as `deployed_via = 'wrangler'`. The name-suffix workaround is no longer needed and disappears from the customer-visible widget name. --- .../wrangler/src/__tests__/turnstile.test.ts | 19 +++---------------- packages/wrangler/src/turnstile/create.ts | 12 +----------- 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/packages/wrangler/src/__tests__/turnstile.test.ts b/packages/wrangler/src/__tests__/turnstile.test.ts index 04e6e41a83..0cf4f7323e 100644 --- a/packages/wrangler/src/__tests__/turnstile.test.ts +++ b/packages/wrangler/src/__tests__/turnstile.test.ts @@ -73,16 +73,13 @@ describe("turnstile widget commands", () => { "turnstile widget create Example --domain example.com --domain www.example.com --mode managed" ); - // The `(Wrangler)` suffix is appended to the widget name at creation - // time so wrangler-created widgets are attributable in analytics. - // Matches the shape Spin uses for its dashboard-created widgets. await expect(reqProm).resolves.toEqual({ - name: "Example (Wrangler)", + name: "Example", domains: ["example.com", "www.example.com"], mode: "managed", }); - expect(std.out).toContain("Created Turnstile widget 'Example (Wrangler)'"); + expect(std.out).toContain("Created Turnstile widget 'Example'"); expect(std.out).toContain(widgetFixture.sitekey); expect(std.out).toContain(widgetFixture.secret); expect(std.out).toContain( @@ -93,16 +90,6 @@ describe("turnstile widget commands", () => { expect(std.out).not.toContain("wrangler.jsonc"); }); - it("doesn't double-append the attribution suffix", async ({ expect }) => { - const reqProm = mockWidgetCreate(); - await runWrangler( - "turnstile widget create 'MyWidget (Wrangler)' --domain example.com --mode managed" - ); - - const body = await reqProm; - expect(body.name).toBe("MyWidget (Wrangler)"); - }); - it("creates a widget with optional fields", async ({ expect }) => { const reqProm = mockWidgetCreate(); await runWrangler( @@ -110,7 +97,7 @@ describe("turnstile widget commands", () => { ); await expect(reqProm).resolves.toEqual({ - name: "Example (Wrangler)", + name: "Example", domains: ["example.com"], mode: "invisible", bot_fight_mode: true, diff --git a/packages/wrangler/src/turnstile/create.ts b/packages/wrangler/src/turnstile/create.ts index c0dbbe3cde..cbda0f1390 100644 --- a/packages/wrangler/src/turnstile/create.ts +++ b/packages/wrangler/src/turnstile/create.ts @@ -35,18 +35,8 @@ export const turnstileWidgetCreateCommand = createCommand({ }, positionalArgs: ["name"], async handler(args, { config }) { - // Append a `(Wrangler)` suffix to the widget name so wrangler-created - // widgets are attributable in the dashboard's widget list and in the - // Turnstile analytics UI (parallel to how the dashboard's Spin flow - // tags widgets with `(Spin)`). The regex matches trailing suffixes - // with optional whitespace to avoid double-appending if a caller - // deliberately included the marker themselves. - const attributed = /\s\(Wrangler\)\s*$/.test(args.name) - ? args.name - : `${args.name.trim()} (Wrangler)`; - const widget = await createWidget(config, { - name: attributed, + name: args.name, domains: args.domain, mode: args.mode, bot_fight_mode: args.botFightMode, From 55c9ec5d5c56b95e7fb8e9eeb9ca7424e0264345 Mon Sep 17 00:00:00 2001 From: Jules Lemee Date: Tue, 7 Jul 2026 09:57:06 -0500 Subject: [PATCH 3/5] [wrangler] Drop turnstile delete try/catch wrapper Let fetchResult errors propagate from deleteWidget rather than rewrapping them as UserError. Matches queues/flagship convention and lets server-side API failures reach Sentry as regular Errors while truly user-driven failures (like a missing sitekey) still surface via fetchResult's own error shaping. The sitekey stays visible in the CLI context so users know which widget failed. --- packages/wrangler/src/turnstile/delete.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/wrangler/src/turnstile/delete.ts b/packages/wrangler/src/turnstile/delete.ts index 76c837ab37..3a2bfac7d7 100644 --- a/packages/wrangler/src/turnstile/delete.ts +++ b/packages/wrangler/src/turnstile/delete.ts @@ -1,4 +1,3 @@ -import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../core/create-command"; import { confirm } from "../dialogs"; import { logger } from "../logger"; @@ -37,15 +36,7 @@ export const turnstileWidgetDeleteCommand = createCommand({ } } - try { - await deleteWidget(config, sitekey); - } catch (err) { - const cause = err instanceof Error ? err.message : String(err); - throw new UserError( - `Failed to delete Turnstile widget ${sitekey}: ${cause}`, - { telemetryMessage: "turnstile widget delete failed" } - ); - } + await deleteWidget(config, sitekey); logger.log(`✅ Deleted Turnstile widget ${sitekey}`); }, }); From b4c818cc448c632aa94362ffb73d10187f264b1f Mon Sep 17 00:00:00 2001 From: Jules Lemee Date: Tue, 7 Jul 2026 15:01:03 -0500 Subject: [PATCH 4/5] [wrangler] Update turnstile changeset to match final create output copy The changeset still said the secret was 'shown only at creation time', which contradicted the create output rewrite that now points users at `wrangler turnstile widget get` for later retrieval. Bring the changelog wording in line with what the command actually prints. --- .changeset/wrangler-turnstile.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wrangler-turnstile.md b/.changeset/wrangler-turnstile.md index 516c61a877..ff375f533a 100644 --- a/.changeset/wrangler-turnstile.md +++ b/.changeset/wrangler-turnstile.md @@ -16,6 +16,6 @@ wrangler turnstile widget delete `create`, `list`, and `update` accept `--json` for clean machine-readable output. `--domain` accepts comma-separated values, e.g. `--domain a.com,b.com`. -`create` prints the sitekey, the secret (shown only at creation time), and the canonical `challenges.cloudflare.com/turnstile/v0/siteverify` endpoint for backend verification. The hint is backend-agnostic; it doesn't assume Workers. `update` strips the secret from its response since it's only incidentally returned. `delete` prompts for confirmation; pass `--skip-confirmation`/`-y` to bypass. +`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. From 26f8d3c7eadc2a388be23e86f6fca818d45586ef Mon Sep 17 00:00:00 2001 From: Jules Lemee Date: Wed, 8 Jul 2026 12:34:57 -0500 Subject: [PATCH 5/5] [wrangler] Add --json to turnstile widget delete Match the Flagship delete pattern: --json requires --skip-confirmation so the interactive prompt cannot corrupt the JSON output. Emits {"sitekey": "...", "success": true} on success; throws a JsonFriendlyFatalError when -y is missing. --- .changeset/wrangler-turnstile.md | 2 +- .../wrangler/src/__tests__/turnstile.test.ts | 22 +++++++++++++++ packages/wrangler/src/turnstile/delete.ts | 27 +++++++++++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.changeset/wrangler-turnstile.md b/.changeset/wrangler-turnstile.md index ff375f533a..102bfea1c8 100644 --- a/.changeset/wrangler-turnstile.md +++ b/.changeset/wrangler-turnstile.md @@ -14,7 +14,7 @@ wrangler turnstile widget update --name "Renamed" wrangler turnstile widget delete ``` -`create`, `list`, and `update` accept `--json` for clean machine-readable output. `--domain` accepts comma-separated values, e.g. `--domain a.com,b.com`. +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. diff --git a/packages/wrangler/src/__tests__/turnstile.test.ts b/packages/wrangler/src/__tests__/turnstile.test.ts index 0cf4f7323e..c8b7f76bd0 100644 --- a/packages/wrangler/src/__tests__/turnstile.test.ts +++ b/packages/wrangler/src/__tests__/turnstile.test.ts @@ -322,6 +322,28 @@ describe("turnstile widget commands", () => { `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 { diff --git a/packages/wrangler/src/turnstile/delete.ts b/packages/wrangler/src/turnstile/delete.ts index 3a2bfac7d7..639371e025 100644 --- a/packages/wrangler/src/turnstile/delete.ts +++ b/packages/wrangler/src/turnstile/delete.ts @@ -1,5 +1,7 @@ +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"; @@ -9,7 +11,10 @@ export const turnstileWidgetDeleteCommand = createCommand({ status: "alpha", owner: "Product: Turnstile", }, - behaviour: { supportTemporary: true }, + behaviour: { + supportTemporary: true, + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, args: { sitekey: { type: "string", @@ -22,9 +27,23 @@ export const turnstileWidgetDeleteCommand = createCommand({ 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 }, { config }) { + 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?`, @@ -37,6 +56,10 @@ export const turnstileWidgetDeleteCommand = createCommand({ } await deleteWidget(config, sitekey); + if (json) { + logger.log(JSON.stringify({ sitekey, success: true }, null, 2)); + return; + } logger.log(`✅ Deleted Turnstile widget ${sitekey}`); }, });