Skip to content

Auto-provision the admin-login Turnstile widget (security F1 — wizard half) - #207

Merged
sparkyfen merged 3 commits into
mainfrom
feat/wizard-security-provision
Jul 27, 2026
Merged

sparkyfen merged 3 commits into
mainfrom
feat/wizard-security-provision

Conversation

@sparkyfen

@sparkyfen sparkyfen commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Supplies the credentials that #189 consumes. #189 makes /admin/login enforce Turnstile when TURNSTILE_SITEKEY + TURNSTILE_SECRET are both set; nothing yet creates the widget or sets those keys. This adds that half.

What's here

Turnstile is account-scoped and needs no zone, so this is gated only on the fork having a custom domain, not on zone resolution.

Second commit: a real bug, found the hard way

WIDGET_NAME alone was the reconciliation key. One Cloudflare account can hold several forks, and every fork's widget carries that same name — so .find() returns whichever is listed first, and provisioning a second fork adopts the first fork's sitekey. A widget scoped to the wrong domain fails every siteverify, and because #189 is fail-closed on both keys, the result is a locked admin login, not a fallback to the throttle.

Matching now requires domains to contain the fork's host. A widget with no domains is treated as not ours, keeping the failure direction at duplicate widget (harmless, and already the documented tradeoff) rather than wrong-domain reuse.

Surfaced while provisioning widgets by hand across four forks — two of them share one account, which is exactly the colliding case. Three regression tests, each verified to fail against the old matcher.

Merge order

Land this before #189. Reversed, a fork gets a login page expecting keys nothing has set — safe, since enforcement needs both, but it means F1 ships doing nothing.

Known gap, not in this PR

The wizard writes straight to Pages, which covers new forks only. Already-deployed forks need a deploy.yml step syncing the repo secret/var to Pages (same reason CRON_SECRET/SETUP_TOKEN have one — Pages secrets bind at deploy time). Left out deliberately: #205 already modifies deploy.yml, so that step should land inside or after it. A standalone backfill runner (the Turnstile analogue of apply-download-ratelimit.ts) is also still missing.

Verification

npm test 1062/1062 · npm run check 0 errors

Summary by CodeRabbit

  • New Features

    • Custom-domain installations now automatically provision and configure a Cloudflare Turnstile widget for the admin login.
    • Existing compatible widgets are reused, avoiding unnecessary duplicate setup.
    • Setup output now reports whether the bot check was created, reused, or could not be configured.
  • Documentation

    • Added the required conditional Cloudflare permission for Turnstile when attaching a custom domain.
    • Clarified that missing permission may leave the admin-login bot check unconfigured.

Mirror the F5 WAF provisioning pattern for finding F1 (admin-login
brute-force protection): a unit-testable turnstile-lib that idempotently
creates (or reuses, by stable name) an account-level Turnstile widget for
the fork's custom domain and returns its sitekey + secret.

setup.ts wires the public sitekey as the TURNSTILE_SITEKEY Pages var and
the secret as the TURNSTILE_SECRET Pages secret, non-fatal when the token
lacks Account - Turnstile - Edit and skipped for pages.dev-only forks.
Adds the Turnstile scope to the token recipe + README table (WAF scope is
added by the F5 branch). The widget secret is never logged.
WIDGET_NAME was the sole reconciliation key, but one Cloudflare account
can hold several forks and every fork's widget carries that same name.
`.find()` then returns whichever was listed first, so provisioning a
second fork adopts the first fork's sitekey — a widget scoped to the
wrong domain. Every siteverify fails, and since F1 gates login on both
keys being set (fail-closed), the admin login locks rather than falling
back to the throttle.

Match on name AND `domains` containing the fork's host. A widget with no
domains field is treated as not ours, so the failure direction is always
a duplicate widget (harmless, already the documented tradeoff) rather
than a wrong-domain reuse.

Found while provisioning widgets by hand across four forks: sparky.ink
and akito.dog live in one account, which is exactly the colliding case.

Three regression tests, each verified to fail against the old matcher.
Existing reuse fixtures gain the `domains` the real API always returns.
@sparkyfen sparkyfen added admin Admin panel / CMS onboarding Setup wizard, fork onboarding, self-host setup labels Jul 22, 2026
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sparkyfen, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 477ad323-3d57-4212-bd18-9015d8fb28e9

📥 Commits

Reviewing files that changed from the base of the PR and between 6ddd928 and 76c4d2f.

📒 Files selected for processing (2)
  • README.md
  • scripts/setup.ts
📝 Walkthrough

Walkthrough

Adds an idempotent Cloudflare Turnstile widget reconciler, tests its creation, reuse, error, and secret-handling paths, and integrates provisioning and credential wiring into custom-domain setup.

Changes

Custom-domain Turnstile setup

Layer / File(s) Summary
Turnstile provisioning and reconciliation
scripts/turnstile-lib.ts, scripts/turnstile-lib.test.ts
Defines widget contracts and managed-mode creation payloads, reconciles widgets by stable name and host-scoped domain, reuses or creates widgets, returns credentials, and tests error and secret-isolation behavior.
Custom-domain setup wiring
README.md, scripts/setup.ts
Documents the conditional Turnstile permission, provisions the widget during custom-domain setup, adds the sitekey and secret to Pages configuration, and reports the resulting status.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Setup as scripts/setup.ts
  participant Provisioner as provisionTurnstileWidget
  participant Cloudflare as Cloudflare API
  participant Pages as Pages API

  Setup->>Provisioner: Provision widget for custom domain
  Provisioner->>Cloudflare: List and reconcile account widgets
  Cloudflare-->>Provisioner: Status, sitekey, and secret
  Provisioner-->>Setup: Return TurnstileResult
  Setup->>Pages: Write sitekey and secret
  Pages-->>Setup: Complete Pages configuration
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: auto-provisioning the admin-login Turnstile widget.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wizard-security-provision

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/setup.ts`:
- Around line 605-612: Update the Turnstile reporting around turnstileStatus to
track successful sitekey and secret writes independently, rather than treating
provisioning status as configuration. Ensure failed Pages PATCH or putSecret
operations keep the integration unconfigured, add TURNSTILE_SECRET to the
initial CI secret wiring/deploy sync, and print the enabled/configured message
only when both credentials were successfully written.

In `@scripts/turnstile-lib.ts`:
- Line 111: Update the widget lookup around the api call in the Turnstile setup
flow to paginate through all challenge widgets, filtering results by the
sona-admin-login name before deciding to create one. Continue requesting
subsequent pages until the full result set is searched, and only invoke the
existing creation path when no matching widget is found.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 855430ab-9fbb-4d22-a3e1-6bb8fc5488bd

📥 Commits

Reviewing files that changed from the base of the PR and between d590ff2 and 6ddd928.

📒 Files selected for processing (4)
  • README.md
  • scripts/setup.ts
  • scripts/turnstile-lib.test.ts
  • scripts/turnstile-lib.ts

Comment thread scripts/setup.ts
Comment on lines +605 to +612
// Admin-login Turnstile (finding F1). 'error' = token lacked the scope, so the
// login has no bot check; otherwise the sitekey/secret are wired and enforced.
if (turnstileStatus === 'error') {
console.log(' • Admin-login bot check: NOT set (token lacks Account · Turnstile · Edit).');
console.log(' Add that permission to the token and re-run setup to protect /admin/login.');
} else if (turnstileStatus) {
console.log(` • Admin-login bot check: Turnstile ${turnstileStatus} for ${host}`);
console.log(' (TURNSTILE_SITEKEY var + TURNSTILE_SECRET secret set; enforced once deployed).');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not report Turnstile as enabled before its credentials are actually wired.

turnstileStatus reflects provisioning only (Line 336). A failed Pages PATCH or swallowed putSecret failure still reaches this success message, and the GitHub-secret list omits TURNSTILE_SECRET for the first CI deployment. Track successful sitekey and secret writes separately, include the secret in CI wiring/deploy sync, and print “configured” only when both succeed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/setup.ts` around lines 605 - 612, Update the Turnstile reporting
around turnstileStatus to track successful sitekey and secret writes
independently, rather than treating provisioning status as configuration. Ensure
failed Pages PATCH or putSecret operations keep the integration unconfigured,
add TURNSTILE_SECRET to the initial CI secret wiring/deploy sync, and print the
enabled/configured message only when both credentials were successfully written.

Comment thread scripts/turnstile-lib.ts
if (!host) return { status: 'error', detail: 'no domain given' };

// 1. List existing widgets; reconcile against ours by stable name.
const listRes = await api(cfToken, `/accounts/${accountId}/challenges/widgets?per_page=50`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant file outline =="
ast-grep outline scripts/turnstile-lib.ts --view expanded || true

echo
echo "== surrounding lines =="
nl -ba scripts/turnstile-lib.ts | sed -n '1,220p'

echo
echo "== search for turnstile widget reconciliation usages =="
rg -n "widgets\?per_page|challenges/widgets|provisionTurnstileWidget|turnstile" scripts -S

Repository: sona-fast/sona

Length of output: 867


🌐 Web query:

Cloudflare Turnstile widgets list API pagination name filter per_page 1000

💡 Result:

The Cloudflare Turnstile API for listing widgets supports pagination and filtering through query parameters [1][2]. Pagination The API supports pagination via the following query parameters: - page: The page number of paginated results [1][2]. - per_page: The number of items per page [1][2]. According to technical documentation, the maximum value supported for per_page is 1000 [3]. Filtering You can filter the list of widgets using the filter query parameter [1][4]. - Format: The filter is applied using a field:value string format (e.g., filter=name:my-widget) [2][4]. - Supported Fields: - name: Filters by widget name (case-insensitive substring matching) [1][4]. - sitekey: Filters by sitekey [2][4]. - Behavior: Providing an empty filter value will return all results [2][4]. The API will return a 400 Bad Request error if an unsupported field or invalid format is used [2][4]. Example Usage To list widgets with a custom page size, you would append the query parameters to your request: curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/challenges/widgets?per_page=1000&filter=name:example" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
grep -n "" scripts/turnstile-lib.ts | sed -n '80,150p'

Repository: sona-fast/sona

Length of output: 3554


Paginate the widget lookup. The current call only reads the first 50 widgets, so a rerun can miss an existing sona-admin-login widget and create a duplicate. Filter by name and page through the full result set before creating a new widget.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/turnstile-lib.ts` at line 111, Update the widget lookup around the
api call in the Turnstile setup flow to paginate through all challenge widgets,
filtering results by the sona-admin-login name before deciding to create one.
Continue requesting subsequent pages until the full result set is searched, and
only invoke the existing creation path when no matching widget is found.

#192 touched the same three places in setup.ts. Both features are additive
and independent, so every conflict keeps both sides: the two lib imports, the
two provisioning calls, the two status variables, and the two Next-steps
blocks.

Nesting matters and is preserved — the WAF apply stays inside the resolved-zone
block, Turnstile stays outside it (account-scoped, needs no zone) but inside
the token check.

TOKEN_RECIPE now lists both new scopes, with Turnstile in the Account group
rather than appended after the Zone ones. Dropped the note telling the reader
not to add Zone · WAF · Edit here — that was a merge-coordination marker for
exactly this merge, and it is now stale.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

admin Admin panel / CMS onboarding Setup wizard, fork onboarding, self-host setup

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant