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,dryRunby 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 writesTURNSTILE_SITE_KEY,TURNSTILE_SECRET_KEYand a paste-readyDOTENVblock as three sensitive fields of one 1Password item namedTurnstile — <App>.- Pair with
@goodcraft/forgemergeEnvto land both keys in a site's.envstraight from that item.
swamp extension pull @swamp/cloudflare/challenges # the base model
swamp extension pull @goodcraft/turnstile # this one- A Cloudflare API token with
Account → Turnstile → Edit(nothing else) and your account ID (Cloudflare dashboard → any zone → Overview → API). - Both in 1Password (the
op-secretsvault): itemCloudflare, fieldsAccount IDandAPI Token. Never paste either into a model definition.
# 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=falseSteps 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=1177111Why per-app: swamp does not evaluate CEL inside vault.get(...), so the
1Password key cannot come from an input (proven 2026-09-02).
ensure—name(exact),domains[],mode(manageddefault,non-interactive,invisible),region(worlddefault),dryRun(truedefault). Writes thewidgetresource:statusofexists,createdorwould-create, plussitekey,domains,mode,region,missingDomains.exportCreds(per-app template) —dryRun(truedefault). Dry run writesenvPlan(key names only); a real run GETs the widget detail and writes thebundleresource, 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.
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.
MIT — see LICENSE.txt.