diff --git a/packages/tiers/src/solvers/turnstile.ts b/packages/tiers/src/solvers/turnstile.ts
new file mode 100644
index 0000000..6a104d5
--- /dev/null
+++ b/packages/tiers/src/solvers/turnstile.ts
@@ -0,0 +1,148 @@
+// Cloudflare Turnstile solver — handles both:
+//
+// 1. Interstitial mode: CF serves a full-page Turnstile challenge BEFORE
+// letting the user through. The challengeWait.ts loop handles this by
+// clicking the iframe widget while polling for cf_clearance.
+//
+// 2. Embedded mode: The page itself contains a
widget.
+// This is used by sites as a form protection (not as a page gate).
+// Solving it generates a turnstile token in the hidden input, which the
+// page's JS can then use to allow form submission or reveal content.
+//
+// Non-interactive mode: When Cloudflare determines the browser has a good
+// risk score (Camoufox Firefox, residential IP, no automation signals), Turnstile
+// auto-solves without any click — a spinner briefly appears then turns into
+// a green checkmark.
+
+import type { Frame, Page } from "patchright"
+
+// Turnstile iframes can come from multiple CF origin paths depending on region and mode.
+const CF_ORIGINS = ["challenges.cloudflare.com", "cloudflare.com/cdn-cgi", "cdn-cgi/challenge-platform"]
+
+export async function solveTurnstile(page: Page, timeoutMs = 25_000): Promise {
+ const deadline = Date.now() + timeoutMs
+
+ // With Camoufox (Firefox + geoip), Turnstile often auto-solves immediately.
+ // The token lands in the hidden input before the iframe even appears.
+ const existingToken = await page
+ .locator('input[name="cf-turnstile-response"]')
+ .inputValue()
+ .catch(() => "")
+ if (existingToken) {
+ console.log("[turnstile] auto-solved, token:", `${existingToken.slice(0, 20)}...`)
+ return true
+ }
+
+ const allFrames = page.frames()
+ console.log(`[turnstile] ${allFrames.length} frames on page:`, allFrames.map((f) => f.url().slice(0, 80)).join(" | "))
+
+ console.log("[turnstile] waiting for challenge iframe or auto-solve token...")
+
+ // Give managed-mode Turnstile time to auto-verify before attempting click.
+ // On good IPs + Camoufox Firefox fingerprint, CF resolves within 3-8s passively.
+ await new Promise((r) => setTimeout(r, 5000))
+
+ let clickAttempts = 0
+ while (Date.now() < deadline) {
+ // Check all possible token locations (hidden input set by turnstile.js)
+ const token = await page
+ .evaluate(() => {
+ const el = document.querySelector('[name="cf-turnstile-response"]')
+ return el?.value ?? ""
+ })
+ .catch(() => "")
+ if (token) {
+ console.log("[turnstile] solved, token:", `${token.slice(0, 30)}...`)
+ return true
+ }
+
+ // Find the CF challenge frame and attempt click
+ const frames = page.frames()
+ const cfFrame = frames.find(isChallengeFrame)
+ if (cfFrame) {
+ // Try frame-internal click — may succeed if Fission doesn't isolate this frame
+ const clickedInternal = await clickInFrameCheckbox(cfFrame)
+ if (clickedInternal) {
+ console.log("[turnstile] clicked via frame locator (attempt", ++clickAttempts, ")")
+ } else {
+ // Firefox Fission isolates CF iframe in a separate process — frame DOM is inaccessible.
+ // frame.frameElement() gives us the