Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@goodcraft/turnstile

Provision Cloudflare Turnstile widgets from swamp, layered on the official @swamp/cloudflare/challenges model rather than replacing it.

  • ensure (added to @swamp/cloudflare/challenges/widgets) — find-or-create a widget by exact name, idempotent, dryRun by default. Errors when the name is ambiguous or an existing widget doesn't cover the requested hostnames. The record it writes holds the sitekey, domains and mode — never the secret.
  • templates/app-turnstile.ts — a per-app export model you copy once per project. It reads the widget secret fresh from Cloudflare and writes TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY and a paste-ready DOTENV block as three sensitive fields of one 1Password item named Turnstile — <App>.
  • Pair with @goodcraft/forge mergeEnv to land both keys in a site's .env straight from that item.

Installation

swamp extension pull @swamp/cloudflare/challenges   # the base model
swamp extension pull @goodcraft/turnstile           # this one

Prerequisites

  1. A Cloudflare API token with Account → Turnstile → Edit (nothing else) and your account ID (Cloudflare dashboard → any zone → Overview → API).
  2. Both in 1Password (the op-secrets vault): item Cloudflare, fields Account ID and API Token. Never paste either into a model definition.

Usage

# 1. One shared instance of the official widgets model, token from the vault.
#    name/domains/mode are required by the official model but unused by ensure.
swamp model create @swamp/cloudflare/challenges/widgets cf-turnstile \
  --global-arg account_id='${{ vault.get(op-secrets, "Cloudflare/Account ID") }}' \
  --global-arg apiToken='${{ vault.get(op-secrets, "Cloudflare/API Token") }}' \
  --global-arg name=placeholder --global-arg mode=managed \
  --global-arg 'domains:json=["placeholder.invalid"]'

# 2. Plan, then create the widget (dryRun defaults true)
swamp model method run cf-turnstile ensure \
  --input name=example \
  --input 'domains:json=["app.example.dev","app.example.com"]'
swamp model method run cf-turnstile ensure \
  --input name=example \
  --input 'domains:json=["app.example.dev","app.example.com"]' --input dryRun=false

# 3. Per app: copy templates/app-turnstile.ts to extensions/models/example_turnstile.ts,
#    set APP = "Example" and SLUG = "example", then:
swamp model create @local/example-turnstile example-turnstile \
  --global-arg sitekey='${{ data.latest("cf-turnstile", "widget").attributes.sitekey }}' \
  --global-arg accountId='${{ vault.get(op-secrets, "Cloudflare/Account ID") }}' \
  --global-arg apiToken='${{ vault.get(op-secrets, "Cloudflare/API Token") }}'
swamp model method run example-turnstile exportCreds --input dryRun=false
#    → 1Password item "Turnstile — Example" with TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, DOTENV

# 4. Into Forge (needs @goodcraft/forge ≥ 2026.09.02.1)
swamp model method run forge mergeEnv \
  --input server=123456 --input domain=app.example.dev \
  --input vars='${{ vault.get(op-secrets, "Turnstile — Example/DOTENV") }}' --input dryRun=false

Steps 2–4 are one swamp workflow per app in GoodCraft's toolkit (turnstile-cairn is the first; copy it per project and change the three baked literals — widget name, <app>-turnstile model, vault key): ensure → exportCreds → mergeEnv staging → mergeEnv production.

Its inputs: stagingDomain, stagingServer, productionDomain, productionServer, and optional mode (default managed). Integer inputs need the :json suffix on the CLI:

swamp workflow run turnstile-cairn \
  --input stagingDomain=app.cairnscript.dev --input stagingServer:json=1177167 \
  --input productionDomain=app.cairnscript.com --input productionServer:json=1177111

Why per-app: swamp does not evaluate CEL inside vault.get(...), so the 1Password key cannot come from an input (proven 2026-09-02).

Methods

  • ensurename (exact), domains[], mode (managed default, non-interactive, invisible), region (world default), dryRun (true default). Writes the widget resource: status of exists, created or would-create, plus sitekey, domains, mode, region, missingDomains.
  • exportCreds (per-app template) — dryRun (true default). Dry run writes envPlan (key names only); a real run GETs the widget detail and writes the bundle resource, whose three sensitive fields are vaulted.

The official model's get, update, delete, sync, adopt and lookup remain available on the same instance. Avoid create and sync for widgets you care about: the generated model stores the secret in plain state.

How it works

ensure lists the account's widgets (paged, 100 at a time) and matches by trimmed name. Zero matches → POST create (or would-create on dry run); the response secret is discarded. One match → exists, with the requested hostnames diffed against the widget's; a real run refuses to proceed when any are missing, because a widget that doesn't cover a site silently breaks Turnstile there. More than one match → error listing the sitekeys. The pure matching/normalising lives in _lib/turnstile_lib.ts; the paged client in _lib/cloudflare_api.ts; both unit-tested, plus mocked-fetch tests for the method itself.

Why the per-app file: swamp's vaultKey is a static literal, so one reusable model cannot name a 1Password item after the app. Baking the title into a one-file copy (two constants to change) is the honest workaround until interpolated vault keys exist.

License

MIT — see LICENSE.txt.

About

Cloudflare Turnstile provisioning for swamp — idempotent ensure on the official widgets model + per-app 1Password creds export

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages