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

@@ -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