refactor(turnstile-spin): tighten auth handling, expand documentation, and add tldraw-offline matcher

This commit is contained in:
Prad Nukala committed 2026-08-13 21:04:57 -04:00
1 parent 20dc5f4489
commit 03ef5c94ea
14 files changed
+1559 -499

No files matched your search

@@ -5,16 +5,24 @@
# $CLOUDFLARE_API_TOKEN (required)
# $CLOUDFLARE_ACCOUNT_ID (optional; if set, must be one of the token's accounts)
#
# Outputs JSON to stdout, always exits 0. The agent reads `status`:
# "ok" ; selected account passed both Turnstile and Workers scope probes
# "missing_token" ; no token set, or wrangler whoami failed
# "missing_scope" ; token lacks Account.Turnstile:Edit on the selected account
# "missing_workers_scope"; token has Turnstile scope but lacks Workers Scripts on the selected account
# "multiple_accounts" ; token covers >1 accounts and $CLOUDFLARE_ACCOUNT_ID is unset; agent must ask user to pick, set it, and re-run
# "account_mismatch" ; $CLOUDFLARE_ACCOUNT_ID is set but is not in the token's accounts list
# Requires: bash, curl, python3. Optional: a user-approved WRANGLER_BIN for account enumeration.
#
# Human-readable diagnostics go to stderr. The agent surfaces them to the user.
# Outputs JSON to stdout, always exits 0. The agent reads `status`:
# "ok" ; selected account passed the Turnstile Edit-scope probe
# "missing_token" ; no token set, python3 unavailable, or account enumeration failed
# "missing_scope" ; token lacks Account.Turnstile:Edit on the selected account
# "multiple_accounts" ; token covers >1 accounts and $CLOUDFLARE_ACCOUNT_ID is unset
# "account_mismatch" ; $CLOUDFLARE_ACCOUNT_ID is set but is not in the token's accounts list
# "network_failure" ; the Edit-scope probe could not reach the Cloudflare API
# "upstream_failure" ; the Edit-scope probe returned an unexpected upstream response
#
# Account enumeration uses `WRANGLER_BIN whoami --json` only when WRANGLER_BIN is
# an approved canonical absolute path outside PROJECT_ROOT and WRANGLER_VERSION
# matches it exactly. Otherwise the caller must supply $CLOUDFLARE_ACCOUNT_ID.
#
# Human-readable diagnostics go to stderr.
set +x
set -uo pipefail
emit() {
@@ -22,75 +30,256 @@ emit() {
exit 0
}
if ! command -v python3 >/dev/null 2>&1; then
echo "auth-probe: python3 is required but not found in PATH." >&2
emit '{"status":"missing_token","reason":"python3_not_available"}'
fi
token="${CLOUDFLARE_API_TOKEN:-}"
unset CLOUDFLARE_API_TOKEN
declared_account="${CLOUDFLARE_ACCOUNT_ID:-}"
if [ -z "$token" ]; then
echo "auth-probe: \$CLOUDFLARE_API_TOKEN is not set." >&2
emit '{"status":"missing_token","reason":"no_env_var"}'
fi
whoami_json=$(npx wrangler whoami --json 2>/dev/null || true)
if [ -z "$whoami_json" ] || [ "$(echo "$whoami_json" | head -c 1)" != "{" ]; then
echo "auth-probe: wrangler whoami returned no JSON. Token may be invalid or expired." >&2
emit '{"status":"missing_token","reason":"whoami_failed"}'
if [[ ! "$token" =~ ^[A-Za-z0-9_-]+$ ]]; then
echo "auth-probe: CLOUDFLARE_API_TOKEN has an invalid format." >&2
emit '{"status":"missing_token","reason":"invalid_token_format"}'
fi
# Extract the accounts array. Fall back to python3 if jq is missing.
accounts_json=$(echo "$whoami_json" | (jq -c '.accounts' 2>/dev/null || python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['accounts']))"))
account_count=$(echo "$accounts_json" | (jq 'length' 2>/dev/null || python3 -c "import sys,json; print(len(json.load(sys.stdin)))"))
accounts_json=""
account_count=0
if [ -z "$account_count" ] || [ "$account_count" = "0" ] || [ "$account_count" = "null" ]; then
echo "auth-probe: wrangler whoami succeeded but no accounts found on the token." >&2
if [ -n "${WRANGLER_BIN:-}" ]; then
if [[ "$WRANGLER_BIN" != /* || ! -x "$WRANGLER_BIN" ]]; then
echo "auth-probe: WRANGLER_BIN must be an executable absolute path." >&2
emit '{"status":"missing_token","reason":"invalid_wrangler_path"}'
fi
wrangler_bin=$(python3 -I -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$WRANGLER_BIN")
if [ "$wrangler_bin" != "$WRANGLER_BIN" ]; then
echo "auth-probe: WRANGLER_BIN must be canonical, without symlinks." >&2
emit '{"status":"missing_token","reason":"noncanonical_wrangler_path"}'
fi
if [ -n "${PROJECT_ROOT:-}" ]; then
project_root=$(python3 -I -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$PROJECT_ROOT")
if [[ "$wrangler_bin" == "$project_root" || "$wrangler_bin" == "$project_root/"* ]]; then
echo "auth-probe: WRANGLER_BIN must be outside PROJECT_ROOT." >&2
emit '{"status":"missing_token","reason":"project_local_wrangler"}'
fi
fi
if [ -z "${WRANGLER_VERSION:-}" ]; then
echo "auth-probe: WRANGLER_VERSION is required with WRANGLER_BIN." >&2
emit '{"status":"missing_token","reason":"missing_wrangler_version"}'
fi
actual_version=$(
"$wrangler_bin" --version 2>/dev/null |
python3 -I -c 'import re,sys; m=re.search(r"\b(\d+\.\d+\.\d+)\b", sys.stdin.read()); print(m.group(1) if m else "")'
)
if [ "$actual_version" != "$WRANGLER_VERSION" ]; then
echo "auth-probe: WRANGLER_BIN version does not match WRANGLER_VERSION." >&2
emit '{"status":"missing_token","reason":"wrangler_version_mismatch"}'
fi
whoami_json=$(CLOUDFLARE_API_TOKEN="$token" "$wrangler_bin" whoami --json 2>/dev/null || true)
if [ -n "$whoami_json" ] && [ "$(printf '%s' "$whoami_json" | head -c 1)" = "{" ]; then
accounts_json=$(printf '%s' "$whoami_json" | python3 -I -c '
import json, sys
try:
d = json.load(sys.stdin)
print(json.dumps(d.get("accounts") or []))
except Exception:
print("[]")
')
account_count=$(printf '%s' "$accounts_json" | python3 -I -c '
import json, sys
try:
print(len(json.load(sys.stdin)))
except Exception:
print(0)
')
fi
fi
if [ "$account_count" = "0" ] && [ -n "$declared_account" ]; then
# No wrangler, but user gave us an account. Trust it and skip enumeration.
accounts_json="[{\"id\":$(python3 -I -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$declared_account")}]"
account_count=1
fi
if [ "$account_count" = "0" ]; then
echo "auth-probe: could not enumerate accounts. Export CLOUDFLARE_ACCOUNT_ID or provide an approved WRANGLER_BIN and WRANGLER_VERSION." >&2
emit '{"status":"missing_token","reason":"no_accounts"}'
fi
# Pick the account to probe:
# - $CLOUDFLARE_ACCOUNT_ID set: must be in the token's accounts list, else account_mismatch
# - unset, exactly 1 account: use it silently
# - unset, >1 accounts: emit multiple_accounts; agent picks and re-runs
if [ -n "$declared_account" ]; then
in_list=$(echo "$accounts_json" | (jq --arg id "$declared_account" 'map(.id) | index($id) != null' 2>/dev/null || python3 -c "import sys,json; print('true' if any(a['id']==sys.argv[1] for a in json.load(sys.stdin)) else 'false')" "$declared_account"))
in_list=$(printf '%s' "$accounts_json" | python3 -I -c '
import json, sys
target = sys.argv[1]
try:
accounts = json.load(sys.stdin)
except Exception:
print("false"); sys.exit(0)
print("true" if any((a or {}).get("id") == target for a in accounts) else "false")
' "$declared_account")
if [ "$in_list" != "true" ]; then
echo "auth-probe: \$CLOUDFLARE_ACCOUNT_ID ($declared_account) is not one of the token's accounts." >&2
echo "auth-probe: Either unset \$CLOUDFLARE_ACCOUNT_ID, or set it to an account included in the token's Account Resources." >&2
emit "{\"status\":\"account_mismatch\",\"declared\":\"$declared_account\",\"accounts\":$accounts_json}"
emit "$(python3 -I -c '
import json, sys
declared, accounts_raw = sys.argv[1], sys.argv[2]
try:
accounts = json.loads(accounts_raw)
except Exception:
accounts = []
print(json.dumps({"status":"account_mismatch","declared":declared,"accounts":accounts}))
' "$declared_account" "$accounts_json")"
fi
account_id="$declared_account"
elif [ "$account_count" = "1" ]; then
account_id=$(echo "$accounts_json" | (jq -r '.[0].id' 2>/dev/null || python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])"))
account_id=$(printf '%s' "$accounts_json" | python3 -I -c '
import json, sys
try:
print(json.load(sys.stdin)[0]["id"])
except Exception:
print("")
')
if [ -z "$account_id" ]; then
echo "auth-probe: accounts list had one entry but no id field." >&2
emit '{"status":"missing_token","reason":"malformed_accounts"}'
fi
else
echo "auth-probe: token covers $account_count accounts; ask the user to pick one, then export \$CLOUDFLARE_ACCOUNT_ID and re-run." >&2
emit "{\"status\":\"multiple_accounts\",\"accounts\":$accounts_json}"
emit "$(python3 -I -c '
import json, sys
try:
accounts = json.loads(sys.argv[1])
except Exception:
accounts = []
print(json.dumps({"status":"multiple_accounts","accounts":accounts}))
' "$accounts_json")"
fi
# Probe Turnstile scope on the selected account
tmp=$(mktemp)
http_code=$(curl -sS -w "%{http_code}" -o "$tmp" \
"https://api.cloudflare.com/client/v4/accounts/$account_id/challenges/widgets" \
-H "Authorization: Bearer $token" 2>/dev/null || echo "000")
body=$(cat "$tmp"); rm -f "$tmp"
success=$(echo "$body" | (jq -r '.success' 2>/dev/null || echo "false"))
# Edit-scope probe. A GET /challenges/widgets would authorize a Read-only
# token; to verify Edit specifically, POST with an intentionally invalid
# payload and interpret the response:
# 401 or 403 → token lacks Edit
# 200 with success:false, errors[0].code=10000 → token lacks Edit
# 400/422 or 200 with validation error codes → Edit scope OK
#
# The API rejects the empty-name/empty-domains payload with 400 today, so
# no widget is created. If validation ever loosens and the probe accidentally
# creates one, we detect the returned sitekey and DELETE it as a safety net
# so the probe stays side-effect-free.
account_enc=$(python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$account_id")
if [ "$success" != "true" ]; then
echo "auth-probe: token cannot read /challenges/widgets on account $account_id (HTTP $http_code). Missing Account.Turnstile:Edit." >&2
emit "{\"status\":\"missing_scope\",\"account_id\":\"$account_id\",\"http_code\":$http_code}"
if ! probe_response="$(
printf 'header = "Authorization: Bearer %s"\n' "$token" |
curl --disable --config - --silent --show-error --write-out $'\n%{http_code}' -X POST \
"https://api.cloudflare.com/client/v4/accounts/$account_enc/challenges/widgets" \
-H "Content-Type: application/json" \
--data '{"name":"","domains":[]}'
)"; then
echo "auth-probe: network failure probing Edit scope on account $account_id." >&2
emit '{"status":"network_failure","account_id":"'"$account_id"'"}'
fi
# Probe Workers scope on the selected account. GET /workers/scripts requires
# Account.Workers Scripts:Read, which is a best-effort proxy for Edit. Tokens
# granted Edit-only (without Read) will fail this probe and emit a confusing
# missing_workers_scope; the agent should suggest adding Read alongside Edit.
tmp=$(mktemp)
workers_code=$(curl -sS -w "%{http_code}" -o "$tmp" \
"https://api.cloudflare.com/client/v4/accounts/$account_id/workers/scripts" \
-H "Authorization: Bearer $token" 2>/dev/null || echo "000")
workers_body=$(cat "$tmp"); rm -f "$tmp"
workers_success=$(echo "$workers_body" | (jq -r '.success' 2>/dev/null || echo "false"))
edit_code="${probe_response##*$'\n'}"
probe_body="${probe_response%$'\n'*}"
probe_output=$(printf '%s' "$probe_body" | python3 -I -c '
import json, sys
http_code = sys.argv[1]
verdict = "unknown"
created_sitekey = ""
try:
raw = sys.stdin.read()
data = json.loads(raw) if raw else {}
except Exception:
data = None
if isinstance(data, dict):
errors = data.get("errors") or []
if not isinstance(errors, list):
errors = []
first = (errors[0] or {}) if errors else {}
if not isinstance(first, dict):
first = {}
first_code = first.get("code", 0)
if http_code in ("401", "403"):
verdict = "missing_scope"
elif http_code == "200" and data.get("success") is False and first_code == 10000:
verdict = "missing_scope"
elif http_code in ("400", "422"):
verdict = "scope_ok"
elif http_code == "200":
# Any 200 that got past auth means scope is fine (whether success or not).
verdict = "scope_ok"
else:
verdict = f"unexpected_{http_code}"
# Detect accidental widget creation (safety net if API validation ever
# accepts the empty-name/empty-domains probe payload).
result = data.get("result")
if isinstance(result, dict) and data.get("success") is True:
sk = result.get("sitekey", "")
if isinstance(sk, str) and sk:
created_sitekey = sk
print(f"{verdict}|{created_sitekey}")
' "$edit_code")
unset probe_body probe_response
verdict="${probe_output%%|*}"
created_sitekey="${probe_output#*|}"
[ "$created_sitekey" = "$probe_output" ] && created_sitekey=""
if [ "$workers_success" != "true" ]; then
echo "auth-probe: token cannot read /workers/scripts on account $account_id (HTTP $workers_code). Missing Account.Workers Scripts:Edit." >&2
emit "{\"status\":\"missing_workers_scope\",\"account_id\":\"$account_id\",\"http_code\":$workers_code}"
# If the probe unexpectedly created a widget (API validation loosened),
# DELETE it so the probe stays side-effect-free.
if [ -n "$created_sitekey" ]; then
echo "auth-probe: probe unexpectedly created widget $created_sitekey; cleaning up..." >&2
sk_enc=$(python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$created_sitekey")
cleanup_code=$(
printf 'header = "Authorization: Bearer %s"\n' "$token" |
curl --disable --config - --silent --show-error --output /dev/null --write-out "%{http_code}" -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/$account_enc/challenges/widgets/$sk_enc" || echo "000"
)
case "$cleanup_code" in
2*) echo "auth-probe: cleanup DELETE for widget $created_sitekey succeeded (HTTP $cleanup_code)." >&2 ;;
*) echo "auth-probe: cleanup DELETE for widget $created_sitekey FAILED (HTTP $cleanup_code). Please remove it from the Turnstile dashboard manually." >&2 ;;
esac
fi
emit "{\"status\":\"ok\",\"account_id\":\"$account_id\",\"accounts\":$accounts_json}"
case "$verdict" in
scope_ok)
emit "$(python3 -I -c '
import json, sys
account_id, accounts_raw = sys.argv[1], sys.argv[2]
try:
accounts = json.loads(accounts_raw)
except Exception:
accounts = []
print(json.dumps({"status":"ok","account_id":account_id,"accounts":accounts}))
' "$account_id" "$accounts_json")"
;;
missing_scope)
echo "auth-probe: token cannot write /challenges/widgets on account $account_id (HTTP $edit_code). Missing Account.Turnstile:Edit." >&2
emit "$(python3 -I -c '
import json, sys
account_id, http_code = sys.argv[1], sys.argv[2]
try:
code_num = int(http_code)
except ValueError:
code_num = 0
print(json.dumps({"status":"missing_scope","account_id":account_id,"http_code":code_num}))
' "$account_id" "$edit_code")"
;;
*)
echo "auth-probe: unexpected response probing Edit scope on account $account_id (HTTP $edit_code)." >&2
emit "$(python3 -I -c '
import json, sys
account_id, http_code = sys.argv[1], sys.argv[2]
try:
code_num = int(http_code)
except ValueError:
code_num = 0
print(json.dumps({"status":"upstream_failure","account_id":account_id,"http_code":code_num}))
' "$account_id" "$edit_code")"
;;
esac
@@ -1,53 +1,114 @@
#!/usr/bin/env bash
# Persists the canonical Spin skill bundle (SKILL.md + scripts/ + references/)
# from cloudflare/skills to the user's repo so the agent can re-load it on
# follow-up tasks without re-pasting the bootstrap prompt.
#
# Args:
# --path <path> SKILL.md destination, e.g. .claude/skills/turnstile-spin/SKILL.md.
# The bundle is extracted into the parent directory of <path>,
# so scripts land at e.g. .claude/skills/turnstile-spin/scripts/.
#
# Outputs JSON. Exit 0 if the bundle was written, 1 on failure.
# ok: {"status":"ok","path":"<path>","bundle_root":"<dir>","scripts":[<list>]}
# fail: {"status":"error","reason":"<reason>"}
# Persists the canonical Spin skill bundle into the current project.
set +x
set -uo pipefail
unset CLOUDFLARE_API_TOKEN CF_API_TOKEN CLOUDFLARE_API_KEY CF_API_KEY
unset CLOUDFLARE_EMAIL CF_API_EMAIL WIDGET_SECRET TURNSTILE_SECRET
unset WRANGLER_BIN WRANGLER_VERSION
unset GITHUB_TOKEN GH_TOKEN GITLAB_TOKEN NPM_TOKEN
need_arg() {
if [[ -z "${2-}" || "$2" == --* ]]; then
echo "persist-skill: missing value for $1" >&2
exit 2
fi
}
PATH_ARG=""
while [[ $# -gt 0 ]]; do
case $1 in
--path) PATH_ARG="$2"; shift 2 ;;
case "$1" in
--path) need_arg "$1" "${2-}"; PATH_ARG="$2"; shift 2 ;;
*) echo "persist-skill: unknown arg $1" >&2; exit 2 ;;
esac
done
: "${PATH_ARG:?--path required}"
[[ -n "$PATH_ARG" ]] || { echo "persist-skill: --path required" >&2; exit 2; }
if [[ "$(basename "$PATH_ARG")" != "SKILL.md" ]]; then
echo "persist-skill: --path must end in SKILL.md for a directory-based skill bundle" >&2
echo '{"status":"error","reason":"file_target_not_supported"}'
exit 2
fi
TARGET_DIR=$(dirname "$PATH_ARG")
mkdir -p "$TARGET_DIR"
for command_name in git python3; do
command -v "$command_name" >/dev/null 2>&1 || {
echo "persist-skill: $command_name is required" >&2
echo "{\"status\":\"error\",\"reason\":\"${command_name}_not_available\"}"
exit 1
}
done
# Install the canonical bundle from cloudflare/skills via degit. This writes
# SKILL.md, scripts/, references/, templates/, tests/ into $TARGET_DIR.
if ! npx --yes degit cloudflare/skills/skills/turnstile-spin "$TARGET_DIR" >/dev/null 2>&1; then
echo "persist-skill: degit failed; cannot fetch cloudflare/skills/skills/turnstile-spin." >&2
echo "persist-skill: ensure your network can reach github.com and try again, or install manually." >&2
echo "{\"status\":\"error\",\"reason\":\"degit_failed\"}"
PROJECT_ROOT="$(pwd -P)"
TARGET_DIR="$(python3 -I -c 'import os,sys; print(os.path.realpath(os.path.abspath(sys.argv[1])))' "$(dirname "$PATH_ARG")")"
if [[ "$TARGET_DIR" != "$PROJECT_ROOT" && "$TARGET_DIR" != "$PROJECT_ROOT/"* ]]; then
echo "persist-skill: target must be inside the current project" >&2
echo '{"status":"error","reason":"target_outside_project"}'
exit 1
fi
if [[ -e "$TARGET_DIR" ]] && ! python3 -I -c 'import os,sys; raise SystemExit(0 if not os.listdir(sys.argv[1]) else 1)' "$TARGET_DIR"; then
echo "persist-skill: target directory is not empty" >&2
echo '{"status":"error","reason":"target_not_empty"}'
exit 1
fi
if [ ! -f "$TARGET_DIR/SKILL.md" ]; then
echo "persist-skill: bundle extracted but SKILL.md is missing at $TARGET_DIR/SKILL.md." >&2
echo "{\"status\":\"error\",\"reason\":\"skill_missing\"}"
if ! TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/turnstile-spin-persist.XXXXXX")"; then
echo "persist-skill: could not create a temporary directory" >&2
echo '{"status":"error","reason":"temporary_directory_failed"}'
exit 1
fi
trap 'rm -rf "$TEMP_DIR"' EXIT
if ! git -c core.hooksPath=/dev/null clone \
--quiet \
--depth 1 \
--filter=blob:none \
--sparse \
"https://github.com/cloudflare/skills.git" \
"$TEMP_DIR/repo"; then
echo "persist-skill: clone failed" >&2
echo '{"status":"error","reason":"clone_failed"}'
exit 1
fi
if ! git -C "$TEMP_DIR/repo" -c core.hooksPath=/dev/null sparse-checkout set skills/turnstile-spin; then
echo "persist-skill: sparse checkout failed" >&2
echo '{"status":"error","reason":"sparse_checkout_failed"}'
exit 1
fi
# Make scripts executable so the agent can invoke them directly.
if [ -d "$TARGET_DIR/scripts" ]; then
chmod +x "$TARGET_DIR/scripts"/*.sh 2>/dev/null || true
SOURCE_DIR="$TEMP_DIR/repo/skills/turnstile-spin"
if [[ ! -f "$SOURCE_DIR/SKILL.md" ]]; then
echo "persist-skill: canonical bundle is missing SKILL.md" >&2
echo '{"status":"error","reason":"skill_missing"}'
exit 1
fi
scripts_list=$(ls "$TARGET_DIR/scripts" 2>/dev/null | sed 's/.*/"&"/' | paste -sd, -)
echo "persist-skill: wrote bundle to $TARGET_DIR" >&2
echo "{\"status\":\"ok\",\"path\":\"$PATH_ARG\",\"bundle_root\":\"$TARGET_DIR\",\"scripts\":[$scripts_list]}"
exit 0
python3 -I - "$SOURCE_DIR" "$TARGET_DIR" <<'PY'
import pathlib
import shutil
import sys
source = pathlib.Path(sys.argv[1])
target = pathlib.Path(sys.argv[2])
if target.exists():
target.rmdir()
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, target, dirs_exist_ok=False)
for script in (target / "scripts").glob("*.sh"):
script.chmod(0o755)
PY
python3 -I - "$PATH_ARG" "$TARGET_DIR" <<'PY'
import json
import pathlib
import sys
path_arg, bundle_root = sys.argv[1], pathlib.Path(sys.argv[2])
scripts = sorted(path.name for path in (bundle_root / "scripts").glob("*.sh"))
print(json.dumps({
"status": "ok",
"path": path_arg,
"bundle_root": str(bundle_root),
"scripts": scripts,
}))
PY
@@ -1,87 +1,137 @@
#!/usr/bin/env bash
# Validates a deployed Turnstile siteverify Worker end-to-end.
#
# Reads:
# $CLOUDFLARE_API_TOKEN (required for hostname check)
#
# Args:
# --worker-url <url> Deployed Worker URL (from worker-deploy.sh)
# --account-id <id> Cloudflare account ID
# --sitekey <key> Widget sitekey (from widget-create.sh)
# --expected-domains <a,b,c> Comma-separated domains that must appear in the widget's domains array
#
# Outputs JSON. Exit 0 if all three checks pass, 1 otherwise.
# ok: {"status":"ok"}
# fail: {"status":"error","check":"health|dummy_siteverify|worker_metadata|hostname","detail":"<msg>"}
# Validates a Turnstile widget without placing its secret in arguments,
# exported environment variables, logs, or temporary files.
set -uo pipefail
set +x
set -euo pipefail
usage() {
echo "Usage: printf '%s' \"\$TURNSTILE_SECRET\" | $0 --sitekey <sitekey> --account-id <account-id> --expected-domains '<json-array>'" >&2
exit 2
}
need_arg() {
if [[ -z "${2-}" || "$2" == --* ]]; then
usage
fi
}
SITEKEY=""
ACCOUNT_ID=""
EXPECTED_DOMAINS_JSON=""
while [[ $# -gt 0 ]]; do
case $1 in
--worker-url) WORKER_URL="$2"; shift 2 ;;
--account-id) ACCOUNT_ID="$2"; shift 2 ;;
--sitekey) SITEKEY="$2"; shift 2 ;;
--expected-domains) EXPECTED_DOMAINS="$2"; shift 2 ;;
*) echo "validate: unknown arg $1" >&2; exit 2 ;;
case "$1" in
--sitekey)
need_arg "$1" "${2-}"
SITEKEY="$2"
shift 2
;;
--account-id)
need_arg "$1" "${2-}"
ACCOUNT_ID="$2"
shift 2
;;
--expected-domains)
need_arg "$1" "${2-}"
EXPECTED_DOMAINS_JSON="$2"
shift 2
;;
*) usage ;;
esac
done
[[ -n "$SITEKEY" && -n "$ACCOUNT_ID" && -n "$EXPECTED_DOMAINS_JSON" ]] || usage
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN must be set}"
: "${WORKER_URL:?--worker-url required}"
: "${ACCOUNT_ID:?--account-id required}"
: "${SITEKEY:?--sitekey required}"
: "${EXPECTED_DOMAINS:?--expected-domains required}"
# Check 1: health endpoint
health=$(curl -sSf "${WORKER_URL}/health" 2>/dev/null || echo "")
if [ -z "$health" ] || ! echo "$health" | grep -q '"ok":true'; then
echo "validate: health check failed; $WORKER_URL/health did not return {ok:true,version:...}" >&2
echo "{\"status\":\"error\",\"check\":\"health\",\"detail\":\"worker /health did not respond ok:true\"}"
API_TOKEN="$CLOUDFLARE_API_TOKEN"
unset CLOUDFLARE_API_TOKEN
[[ "$API_TOKEN" =~ ^[A-Za-z0-9_-]+$ ]] || {
echo "validate: CLOUDFLARE_API_TOKEN has an invalid format" >&2
exit 1
fi
}
# Check 2: dummy siteverify; should return success:false + error-codes array
dummy=$(curl -sS -X POST "${WORKER_URL}/" \
-H "Content-Type: application/json" \
-d '{"token":"XXXX.DUMMY.TOKEN.XXXX"}' 2>/dev/null || echo "")
success=$(echo "$dummy" | (jq -r '.success // "missing"' 2>/dev/null || echo "missing"))
errors=$(echo "$dummy" | (jq -r '.["error-codes"] | length // 0' 2>/dev/null || echo "0"))
if [ "$success" != "false" ] || [ "$errors" = "0" ]; then
echo "validate: dummy siteverify check failed; expected success:false + error-codes; got: $dummy" >&2
echo "{\"status\":\"error\",\"check\":\"dummy_siteverify\",\"detail\":\"unexpected response shape\"}"
exit 1
fi
# Check 2b: confirm the Worker is the managed template (not a customer-written
# replacement) by looking for the _worker metadata field. If absent, the user
# deployed a custom Worker; surface it so the agent can alert them.
worker_meta=$(echo "$dummy" | (jq -r '._worker.worker_version // "missing"' 2>/dev/null || echo "missing"))
if [ "$worker_meta" = "missing" ]; then
echo "validate: _worker metadata missing from response; this is not the managed Spin Worker template." >&2
echo "{\"status\":\"error\",\"check\":\"worker_metadata\",\"detail\":\"_worker field missing; user may have deployed a custom Worker\"}"
exit 1
fi
# Check 3: hostname / widget domains registered
widget=$(curl -sS \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/challenges/widgets/$SITEKEY" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" 2>/dev/null)
registered=$(echo "$widget" | (jq -r '.result.domains[]' 2>/dev/null || python3 -c "import sys,json; [print(d) for d in json.load(sys.stdin)['result']['domains']]"))
missing=""
IFS=',' read -ra DOMS <<< "$EXPECTED_DOMAINS"
for d in "${DOMS[@]}"; do
if ! echo "$registered" | grep -qFx "$d"; then
missing="${missing}${d} "
fi
for command_name in curl jq python3; do
command -v "$command_name" >/dev/null 2>&1 || {
echo "validate: $command_name is required" >&2
exit 1
}
done
if [ -n "$missing" ]; then
echo "validate: hostname check failed; domains not on widget: $missing" >&2
echo "{\"status\":\"error\",\"check\":\"hostname\",\"detail\":\"missing domains: ${missing% }\"}"
if ! jq -e '
type == "array" and
length > 0 and
all(.[]; type == "string" and length > 0)
' <<<"$EXPECTED_DOMAINS_JSON" >/dev/null; then
echo "validate: --expected-domains must be a non-empty JSON array of domains" >&2
exit 2
fi
WIDGET_SECRET=""
IFS= read -r -d '' WIDGET_SECRET || true
trap 'unset API_TOKEN WIDGET_SECRET WIDGET_API_SECRET WIDGET_RESPONSE SITEVERIFY_RESPONSE' EXIT
if [[ -z "$WIDGET_SECRET" || "$WIDGET_SECRET" =~ [[:space:]] ]]; then
echo "validate: standard input must contain one non-empty secret without whitespace" >&2
exit 1
fi
echo '{"status":"ok"}'
ACCOUNT_ENCODED="$(python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$ACCOUNT_ID")"
SITEKEY_ENCODED="$(python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$SITEKEY")"
if ! WIDGET_RESPONSE="$(
printf 'header = "Authorization: Bearer %s"\n' "$API_TOKEN" |
curl --disable --config - --fail --silent --show-error \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ENCODED/challenges/widgets/$SITEKEY_ENCODED"
)"; then
echo "validate: widget metadata lookup failed" >&2
exit 1
fi
if ! printf '%s' "$WIDGET_RESPONSE" | jq -e --arg sitekey "$SITEKEY" --argjson expected "$EXPECTED_DOMAINS_JSON" '
. as $widget
| (.success == true) and
(.result.sitekey == $sitekey) and
((.result.clearance_level | type) == "string") and
(.result.clearance_level as $clearance | ["no_clearance", "interactive", "managed", "jschallenge"] | index($clearance) != null) and
((.result.domains | type) == "array") and
(all($expected[]; . as $domain | $widget.result.domains | index($domain) != null))
' >/dev/null; then
echo "validate: widget sitekey, domains, or clearance level was invalid" >&2
exit 1
fi
if ! WIDGET_API_SECRET="$(printf '%s' "$WIDGET_RESPONSE" | jq -er '.result.secret | select(type == "string" and test("^\\S+$"))')"; then
echo "validate: widget metadata did not include a valid secret" >&2
exit 1
fi
if [[ "$WIDGET_API_SECRET" != "$WIDGET_SECRET" ]]; then
echo "validate: secret does not belong to the requested sitekey" >&2
exit 1
fi
unset WIDGET_API_SECRET
unset WIDGET_RESPONSE
if ! SITEVERIFY_RESPONSE="$(
printf '%s' "$WIDGET_SECRET" |
python3 -I -c 'import sys,urllib.parse; print(urllib.parse.urlencode({"secret":sys.stdin.read(),"response":"XXXX.DUMMY.TOKEN.XXXX"}),end="")' |
curl --disable --fail --silent --show-error \
"https://challenges.cloudflare.com/turnstile/v0/siteverify" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-binary @-
)"; then
echo "validate: dummy-token siteverify request failed" >&2
exit 1
fi
if ! jq -e '
(.success == false) and
((.["error-codes"] | type) == "array") and
((.["error-codes"] | index("invalid-input-response")) != null) and
((.["error-codes"] | index("invalid-input-secret")) == null)
' <<<"$SITEVERIFY_RESPONSE" >/dev/null; then
echo "validate: siteverify did not confirm the widget secret" >&2
exit 1
fi
unset WIDGET_SECRET SITEVERIFY_RESPONSE
echo '{"status":"ok","metadata_check":"ran","dummy_siteverify":"ran"}'
@@ -1,57 +1,121 @@
#!/usr/bin/env bash
# Creates a Turnstile widget via the Cloudflare API.
#
# Reads:
# $CLOUDFLARE_API_TOKEN (required)
#
# Args:
# --account-id <id> Cloudflare account ID
# --name <name> Widget name (e.g. "myproject (Spin)")
# --domains <a,b,c> Comma-separated domain list (include localhost,127.0.0.1)
# --mode <managed|invisible|non-interactive> Default: managed
#
# Outputs JSON to stdout. Exit 0 on success, 1 on failure. Diagnostics on stderr.
# ok: {"status":"ok","sitekey":"<key>","secret":"<secret>"}
# error: {"status":"error","code":<code>,"message":"<msg>"}
# code 10000 → token lacks Account.Turnstile:Edit
# Creates a Turnstile widget without writing credentials or the response to disk.
set +x
set -uo pipefail
need_arg() {
if [[ -z "${2-}" || "$2" == --* ]]; then
echo "widget-create: missing value for $1" >&2
exit 2
fi
}
MODE="managed"
ACCOUNT_ID=""
NAME=""
DOMAINS=""
while [[ $# -gt 0 ]]; do
case $1 in
--account-id) ACCOUNT_ID="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--domains) DOMAINS="$2"; shift 2 ;;
--mode) MODE="$2"; shift 2 ;;
case "$1" in
--account-id) need_arg "$1" "${2-}"; ACCOUNT_ID="$2"; shift 2 ;;
--name) need_arg "$1" "${2-}"; NAME="$2"; shift 2 ;;
--domains) need_arg "$1" "${2-}"; DOMAINS="$2"; shift 2 ;;
--mode) need_arg "$1" "${2-}"; MODE="$2"; shift 2 ;;
*) echo "widget-create: unknown arg $1" >&2; exit 2 ;;
esac
done
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN must be set}"
: "${ACCOUNT_ID:?--account-id required}"
: "${NAME:?--name required}"
: "${DOMAINS:?--domains required}"
API_TOKEN="$CLOUDFLARE_API_TOKEN"
unset CLOUDFLARE_API_TOKEN
[[ -n "$ACCOUNT_ID" ]] || { echo "widget-create: --account-id required" >&2; exit 2; }
[[ -n "$NAME" ]] || { echo "widget-create: --name required" >&2; exit 2; }
[[ -n "$DOMAINS" ]] || { echo "widget-create: --domains required" >&2; exit 2; }
[[ "$API_TOKEN" =~ ^[A-Za-z0-9_-]+$ ]] || {
echo "widget-create: CLOUDFLARE_API_TOKEN has an invalid format" >&2
exit 1
}
case "$MODE" in
managed|invisible|non-interactive) ;;
*) echo "widget-create: unsupported mode" >&2; exit 2 ;;
esac
domains_json=$(python3 -c "import sys; print(__import__('json').dumps(sys.argv[1].split(',')))" "$DOMAINS")
for command_name in curl python3; do
command -v "$command_name" >/dev/null 2>&1 || {
echo "widget-create: $command_name is required" >&2
exit 1
}
done
body=$(curl -sS -X POST \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/challenges/widgets" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$NAME\",\"domains\":$domains_json,\"mode\":\"$MODE\"}" 2>/dev/null)
BODY_JSON="$(python3 -I -c '
import json, sys
name, domains_csv, mode = sys.argv[1], sys.argv[2], sys.argv[3]
domains = [domain.strip() for domain in domains_csv.split(",") if domain.strip()]
if not domains:
raise SystemExit(2)
print(json.dumps({"name": name, "domains": domains, "mode": mode}))
' "$NAME" "$DOMAINS" "$MODE")" || {
echo "widget-create: --domains must include at least one domain" >&2
exit 2
}
ACCOUNT_ENCODED="$(python3 -I -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$ACCOUNT_ID")"
success=$(echo "$body" | (jq -r '.success' 2>/dev/null || python3 -c "import sys,json; print(str(json.load(sys.stdin).get('success',False)).lower())"))
if [ "$success" = "true" ]; then
sitekey=$(echo "$body" | (jq -r '.result.sitekey' 2>/dev/null || python3 -c "import sys,json; print(json.load(sys.stdin)['result']['sitekey'])"))
secret=$(echo "$body" | (jq -r '.result.secret' 2>/dev/null || python3 -c "import sys,json; print(json.load(sys.stdin)['result']['secret'])"))
echo "{\"status\":\"ok\",\"sitekey\":\"$sitekey\",\"secret\":\"$secret\"}"
exit 0
if ! API_RESPONSE="$(
printf 'header = "Authorization: Bearer %s"\n' "$API_TOKEN" |
curl --disable --config - --silent --show-error --write-out $'\n%{http_code}' -X POST \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ENCODED/challenges/widgets" \
-H "Content-Type: application/json" \
--data "$BODY_JSON"
)"; then
echo "widget-create: Cloudflare API request failed" >&2
echo '{"status":"error","code":0,"message":"Cloudflare API request failed"}'
exit 1
fi
unset BODY_JSON
unset API_TOKEN
code=$(echo "$body" | (jq -r '.errors[0].code // 0' 2>/dev/null || echo "0"))
message=$(echo "$body" | (jq -r '.errors[0].message // "unknown"' 2>/dev/null || echo "unknown") | tr -d '"')
echo "widget-create: failed (code=$code): $message" >&2
echo "{\"status\":\"error\",\"code\":$code,\"message\":\"$message\"}"
exit 1
HTTP_CODE="${API_RESPONSE##*$'\n'}"
RESPONSE_BODY="${API_RESPONSE%$'\n'*}"
unset API_RESPONSE
if ! printf '%s' "$RESPONSE_BODY" | python3 -I -c '
import json
import re
import sys
http_code = sys.argv[1]
try:
data = json.load(sys.stdin)
except Exception:
print(f"widget-create: non-JSON response (HTTP {http_code})", file=sys.stderr)
print(json.dumps({"status":"error","code":0,"message":"Cloudflare API returned an invalid response"}))
raise SystemExit(1)
errors = data.get("errors") if isinstance(data, dict) else []
first = errors[0] if isinstance(errors, list) and errors and isinstance(errors[0], dict) else {}
code = first.get("code", 0)
if not isinstance(data, dict) or data.get("success") is not True:
print(f"widget-create: request failed (HTTP {http_code}, code={code})", file=sys.stderr)
print(json.dumps({"status":"error","code":code,"message":"Cloudflare API request failed"}))
raise SystemExit(1)
result = data.get("result")
sitekey = result.get("sitekey") if isinstance(result, dict) else None
secret = result.get("secret") if isinstance(result, dict) else None
if not (
isinstance(sitekey, str)
and re.fullmatch(r"\S{1,256}", sitekey)
and isinstance(secret, str)
and re.fullmatch(r"\S{1,1024}", secret)
):
print("widget-create: API returned invalid widget credentials", file=sys.stderr)
print(json.dumps({"status":"error","code":0,"message":"Cloudflare API returned invalid widget credentials"}))
raise SystemExit(1)
print(json.dumps({"status":"ok","sitekey":sitekey,"secret":secret}))
' "$HTTP_CODE"; then
unset RESPONSE_BODY
exit 1
fi
unset RESPONSE_BODY