Repository navigation
fix(turnstile): partner hosts fail the captcha with 110200 (unknown domain) - #355
Conversation
Turnstile validates the hostname of the document that RENDERS the challenge, not the top-level page. The script embed puts our tree inside the host's document, so every partner site not on the widget's Turnstile hostname list failed its captcha with 110200 and the visitor got the captcha-blocked screen. The free plan caps that list at 10 hostnames, 5 of which were already spent. The challenge now renders in a small frame served from /challenge.html on the widget's own origin, so Turnstile always sees sahajatlas.com whatever page hosts the widget. One hostname list covers every partner, with no per-site onboarding step and no SahajCloud change. - challenge.html + src/challenge.ts: a third Vite HTML input. Loads api.js under its own policy, renders the site key with the theme and language the parent gives it on the query string, and talks to its parent over postMessage. The reset lives in style ATTRIBUTES because every stylesheet in this build is rewritten to :where(.sy-atlas), which matches nothing here. - src/lib/turnstile-frame.ts: the protocol, shared by both sides of the frame rather than duplicated, so it cannot drift silently. - useTurnstile keeps its public shape. It appends the frame instead of calling turnstile.render, accepts only messages whose source is the frame's own contentWindow and whose origin is the frame's, and posts `reset` instead of calling turnstile.reset. - probeTurnstile mounts the frame in probe mode: the page hands back its handshake and renders nothing, so the eager check spends no solve. It now SEES a frame-src refusal, through securitypolicyviolation, which it could never see before; the hostname mismatch it also could not see is gone rather than unobserved. - CSP_ADVICE names our origin under frame-src, read off the frame URL so a preview and a dev server each name the host they need. - embed.smoke.test.ts crawls the challenge page's graph too: the site key moved there, and the loader's graph alone reads as production carrying no key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjYffj6R24HDkeuBGpj
…height The probe's frame lands in the HOST page's body, and an out-of-flow element at its static position still extends the document's scroll area — a stray pixel of scroll on somebody else's page for the length of the check. `fixed` contributes none. Records what a browser said about the violation the probe reads, measured against the built page in Chromium 153.0.8010.12: a host sending `frame-src 'none'` and one sending only `default-src 'self'` both report `effectiveDirective: 'frame-src'` with `blockedURI` the bare origin, no trailing slash and no path. That is why the handler matches by prefix, and the spec now covers both shapes. docs/embedding.md gains the frame as a seventh exception under what the widget does to a host's page, since a transient element in their body is observable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjYffj6R24HDkeuBGpj
Applies the /simplify pass over the branch. Two of these change behaviour and are the reason the pass was worth running: - `useTurnstile` had no handshake timeout, so a form under a `frame-src` that refuses us sat on `loading` for ever, behind a submit button that could never produce a token. The bound moves into `createChallengeFrame`, which delivers silence as the page's own `error` — the event both callers already answer correctly. One liveness rule, shared. - `probeTurnstile` re-ran on every interface mount. The script load it replaced memoized itself, so a compact embed's open/close/open used to cost nothing and now paid for a browsing context, three chunks and a Cloudflare script eval each time. Memoized, and cleared on a negative verdict so a transient network failure is retried rather than cached for the page's life. The rest is cleanup. The smoke lane's two crawls now share one memo wrapper, one "did the deploy really serve this chunk" rule, and one transitive walk — the challenge page's graph was crawled one level deep, which is the defect #96 recorded against the loader. `/challenge.html` gets a `Cache-Control` rule rather than a host-dependent platform default, and its HTML comment moves into the modules, since Vite strips no HTML comment and the page is fetched inside a frame on the registration path. Three comments had gone false with the rewrite and are corrected: `Fallbacks` justified its retry by `loadTurnstile`'s cleared promise and told hosts to edit `script-src`, `src/mocks/errors.ts` named the wrong directive, and README's privacy bullet still put Cloudflare in the host's own CSP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjYffj6R24HDkeuBGpj
The comment justifying `'*'` as the token's target origin said a framer "can obtain one exactly as they could by loading Turnstile themselves". That reads as "this changes nothing", and it is the reasoning a future author would trust, so it is worth getting right. Half of it holds: minting was never gated, because the production key lists `localhost` and `127.0.0.1`, so anyone could already solve as many tokens as they liked on their own machine. What the open frame adds is a token solved in a third party's visitor's browser — carrying the address SahajCloud passes siteverify as `remoteip`, and a risk score a residential browser earns — and `reset` lets a framer take a stream rather than one. No client-side change fixes it: every framer can replay any handshake a public page defines. The comment now names the two real answers, both out of #343's scope, and points at the question thread on the pull request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjYffj6R24HDkeuBGpj
`_headers` now carries Cache-Control on three root files, not two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjYffj6R24HDkeuBGpj
sydevs-bot
left a comment
There was a problem hiding this comment.
1 question for the reviewer, one per thread.
🤖 Written by the sydevs autonomous loop — see what this is
Generated by Claude Code
| } | ||
|
|
||
| function send(message: ChallengeEvent) { | ||
| window.parent.postMessage(challengeMessage(message), '*') |
There was a problem hiding this comment.
Should /challenge.html stay framable by any origin? Nothing here can tell our widget on a partner's page from a third party's frame, so the token goes out to '*' and reset is accepted unchecked.
A — ship as is, file the follow-up (built, recommended). Minting was never gated: the production key lists localhost, so anyone could already solve freely on their own machine. What is new is a token solved in somebody else's visitor's browser, carrying the address SahajCloud sends siteverify as remoteip.
B — frame-ancestors from an allowlist fetched at runtime off the client records. Closes it properly. Costs a fetch before the challenge can render, and brings back a per-partner list — the thing #343 chose this approach to avoid.
C — bind the token in SahajCloud (action, or siteverify's hostname). verifyTurnstile.ts checks success alone today. Does not stop harvesting; stops a harvested token being re-aimed.
Reply with a letter, or your own answer.
🤖 Written by the sydevs autonomous loop — see what this is
Generated by Claude Code
There was a problem hiding this comment.
Would it make sense to implement this challenge.html in SahajCloud itself and then embed it from there? Then we could have access to the list of valid client URLs in the SahajCloud database. Would that allow us to control this better?
There was a problem hiding this comment.
Yes, and better than my B: that list is already enforced. clients.allowedDomains 403s any API read from an unlisted host, so framing from one is already a broken embed — the allowlist costs no new onboarding.
A — build it here. The page and the site key change repos, and turnstile-frame.ts becomes a cross-repo contract. #343 waits on that.
B — a SahajCloud ticket (recommended). This fix ships now; the page stays openly framable until that lands.
🤖 Written by the sydevs autonomous loop — see what this is
Generated by Claude Code
There was a problem hiding this comment.
Go with B, but we don't want to merge this PR if it will be superseded by that ticket.
There was a problem hiding this comment.
Filed sydevs/SahajCloud#1072, with the empty-allowedDomains case as its open question.
Not superseded in substance: it moves where the page is served, not the fix. Measured here, 238 added lines in 5 files re-home — the page, its Vite input, the _headers row, the Ladle plugin. The other +1404/-409 stand either way.
The real cost is that a host sending a CSP changes frame-src twice. That ticket carries an option avoiding it.
Merging is yours, so this stays open.
🤖 Written by the sydevs autonomous loop — see what this is
Generated by Claude Code
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
sahajatlas-design | 1451d06 | Nov 03 2026, 09:43 PM |
|
🤖 no session running recent dispatchesWritten by the sydevs dispatcher. Sessions never edit this comment. |
|
Smoke is red for a cause outside this branch. I pushed nothing. The publish step failed: "You cannot upload a new version of a Worker that does not yet exist", so no preview existed and no smoke spec ran. Measured, not deduced:
No commit here fixes it — the app Worker is missing from the account CI reads, and This branch's own gate is green on 🤖 Written by the sydevs autonomous loop — see what this is Generated by Claude Code |
… rename) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sydevs-bot
left a comment
There was a problem hiding this comment.
🧐 Adversarial review
The shape is the one #343 decided, and the diff matches it: one code path, our origin, no per-partner step. Twenty-three files, nothing smuggled — every doc, comment and fixture change is a statement this PR makes false, and the smoke refactor is what reaches the second graph the site key moved into. The ladder found no existing owner for the protocol, so turnstile-frame.ts earns its place. Both gaps are outside the app Worker: the challenge page is missing from the Ladle build, and the new cross-origin frame delegates nothing. The body says no preview exists; the design one does, and it found the first one.
Findings
should-fixBoth form stories break on the design playground:/challenge.htmlis not in the Ladle build —use-turnstile.ts:66should-fixThe frame carries noallow, and § Permissions Policy was not revisited —use-turnstile.ts:78considerchallengeOriginhas one caller and re-derives a known origin —turnstile-frame.ts:76considerresetposts to'*'while inbound is origin-pinned —use-turnstile.ts:300
What was checked. Read whole on the branch: the three new modules, challenge.html, both hooks, both forms' turnstile seams, _headers, vite.config.ts, .ladle/*, the smoke diff, and the embedding/CHANGELOG/environment/deployment/testing prose. Contract side: no in-tree reader of the CSP table, and _headers sets no CSP, so our own origin cannot refuse our own frame. App preview is missing (#356), so design.sahajatlas.com and the branch's design alias were fetched directly instead. Ladder walked for challenge.html, challenge.ts, turnstile-frame.ts, test-support/turnstile-frame.ts, createChallengeFrame, HANDSHAKE_TIMEOUT_MS and the probe memo: readiness.ts refuses postMessage by design, and live-preview's transport is Payload's library.
🤖 Written by the sydevs autonomous loop — see what this is
Generated by Claude Code
Both form stories render a live Turnstile widget, which since the move to a framed challenge (#343) means fetching `/challenge.html` from the bundle's own origin. The Ladle build carries none of the app's entries, so that page was absent on `design.sahajatlas.com`: the SPA fallback answered with the Ladle shell, the handshake timed out, and both stories sat `blocked` with Submit disabled — their own descriptions promising the opposite. The page cannot be an input or a `public/` file: Ladle relocates Vite's root to its own app directory, so the page's `/src/challenge.ts` resolves from neither. A plugin substitutes it instead — `/@fs` while serving, an emitted chunk when building — and sets the status explicitly, because Ladle's html fallback has already parked a 404 on the response by then. Measured in Chromium 153 against `pnpm ladle`: the report-issue story mounts 4 challenge frames and registration 6, every one solved, no console errors. `pnpm ladle:build` emits `challenge.html` plus a hashed chunk carrying the test site key. `turnstile-frame.test.ts` pins the substituted script path against drift between the two files, since only a browser pass would notice it. That pin is the nineteenth spec selecting `repo-source.ts`, so the per-edit width ceiling is ratcheted with a fresh timing pair rather than losing a spec: 6.5s for the widest edit and 7.8s for `i18n.ts`, against the 12s budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjVX8C4HGmmaS1jTp2U
`api.js` now runs in a cross-origin child rather than in the host's document, and every policy-controlled feature is denied to such a frame by default. The script embed held them all unless a host's own header said otherwise, so the frame was silently less capable than the arrangement it replaces: Permissions Policy is hierarchical, and the `allow` Turnstile sets on its own widget frame one level further in reaches nothing our document was not granted first. The frame now carries the six features `api.js` asks for, read off it directly: `cross-origin-isolated; fullscreen; autoplay; keyboard-map; gamepad; xr-spatial-tracking`. Private State Tokens are deliberately not among them. `api.js` requests that capability under the pre-standard name `private-token`, gated on `document.featurePolicy.features()` listing it, and Chromium 153 lists only `private-state-token-issuance` and `private-state-token-redemption` — measured. So Turnstile's widget frame asks for it under no name an engine knows, nothing a parent delegates can reach the code that would use it, and nothing in our own document calls the API. Delegating the standard pair would be theatre. The host-facing half lands in `docs/embedding.md` § Permissions Policy and `CHANGELOG.md`: the six are listed because a host's header does reach the frame, with the measurement that the challenge renders and solves with all six denied, so there is nothing for a host to add. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjVX8C4HGmmaS1jTp2U
`createChallengeFrame` pins inbound messages to the origin it built the `src` from, while `reset` went out to `'*'` — whatever document happens to occupy the frame. Carrying that origin on the ref costs a line and keeps the two directions symmetric: a frame navigated elsewhere by a redirect or a captive portal now receives nothing rather than a command. `src/challenge.ts` keeps `'*'` on purpose. It genuinely cannot know its parent, which is the whole reason the hostname list collapsed to one. The spec asserted `expect.anything()` for the target and so held either behaviour. It now names the frame's own origin, and goes red with `'*'` restored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjVX8C4HGmmaS1jTp2U
Two surfaces the challenge-page plugin makes stale. STORYBOOK.md described `.ladle/vite.config.ts` as the alias file alone. `docs/environment.md` said the real Turnstile site key belongs on the app Worker's production trigger, which stays true, but the design Worker is now a place where that key would matter: its build serves its own `/challenge.html`, and `turnstile-release-guard.mjs` lives in the root Vite config, which Ladle never loads — so nothing would fail a design build carrying a real key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjVX8C4HGmmaS1jTp2U
|
Four critic threads: three adopted, one rebutted. The question thread on Adopted
Rebutted
Lean gate green: lint, typecheck, 1952 tests. The per-edit width ceiling ratcheted 18 → 19 on a fresh timing pair, 6.5s and 7.8s against 12s. 🤖 Written by the sydevs autonomous loop — see what this is Generated by Claude Code |
| } | ||
|
|
||
| function send(message: ChallengeEvent) { | ||
| window.parent.postMessage(challengeMessage(message), '*') |
There was a problem hiding this comment.
Would it make sense to implement this challenge.html in SahajCloud itself and then embed it from there? Then we could have access to the list of valid client URLs in the SahajCloud database. Would that allow us to control this better?
Review asked for less code and fewer comments in `use-turnstile.ts`, with
behaviour preserved. Comments drop 145 lines to 107 and code 189 to 182;
every `⚠` point and `#NNN` breadcrumb the file carried is kept, compressed.
Three substantive edits, all behaviour-neutral:
- `deliver` is inlined into `onMessage`. It had one call site, and the
timer-clearing rule it held (clear on a real message, never on a stray one)
is clearer beside the parse it depends on.
- `TURNSTILE_FEATURES` is the joined string rather than an array plus a
`join('; ')` — the value is a literal either way, and nothing iterates it.
- The `availability` memo's rationale moves to `probeTurnstile`'s docblock,
so the one eager-check story is in one place rather than split across a
module-level comment and a body comment.
`use-turnstile` 24, `turnstile-frame` 29 and `use-turnstile-guard` 5 all pass
unchanged, with no spec edited — which is what "preserving behaviour" means
here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mg9rjYk3epghxLxipXmJws
Review asked for less code and fewer comments in `turnstile-frame.ts`, and for reuse in place of recreation. Comments drop 58 lines to 50; every `⚠` point and `#NNN` breadcrumb is kept, compressed. The recreation was real and is gone. `challengeSrc` and `challengeOrigin` each built `new URL(CHALLENGE_PATH, moduleUrl)` of their own; both now go through one `challengeUrl`, so the path and the base resolve in one place. `isRecord` was a private generic predicate with a single caller, folded into `onChannel` by narrowing in place. Reuse from outside the file was looked for and there is none to take. No shared record predicate exists in `src/lib/` — `prefetch-intent.ts` has `isThenable`, a different test. `shape/query.ts` owns this repo's URL-editing helpers, and they are the wrong tool here by their own docblock: they exist to edit one pair on a host's query byte-identically, and argue against `URLSearchParams.set` for that case. This module builds a URL of ours from scratch, where `set` is correct. Importing them would also pull host-query machinery into `challenge.html`'s graph, which carries this module. `turnstile-frame` 29 and both form submit specs pass unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjYk3epghxLxipXmJws
|
Two reviewer threads adopted. The question thread has a fourth option now and still waits on you. Adopted
Every Asked
Merged 🤖 Written by the sydevs autonomous loop — see what this is Generated by Claude Code |
|
One thread answered. No commit here — the answer is a ticket in another repo. Answered
Still yours: whether to merge this before that lands. The thread stays open. Measured on this branch, 238 added lines in 5 files re-home with the page and its build wiring. The other +1404/-409 — the hook, the protocol, the probe, their specs, The cost that is not recoverable: a host that sends a CSP changes Base is current, no merge needed. No code change, so no gate run. Generated by Claude Code |
…s document The challenge page moves to SahajCloud (sydevs/SahajCloud#1072), which now holds both halves of the Turnstile key and decides which pages may receive a token. The frame is `${VITE_SAHAJCLOUD_URL}/api/atlas/challenge?parent=<origin>…`, with `parent` this document's `location.origin`: the page posts every message to that origin alone, and renders nothing for a parent whose domain no client record lists. One page, one key, one allowlist, and the widget carries no Turnstile configuration at all. So everything that existed to serve a page from our own origin goes: `challenge.html`, `src/challenge.ts`, its Vite input, its `_headers` row, the Ladle plugin, `VITE_TURNSTILE_SITE_KEY`, #259's release guard and the smoke case reading the key back. The page-side parsers left `turnstile-frame.ts` with it, so the per-edit width ceiling returns to 18. The probe now answers why it failed — `csp`, `origin` or `failed` — so the console names the one fix that applies: SahajCloud's origin under `frame-src`, or the domain to have added to the client record. A host's CSP needs `frame-src https://cloud.sydevelopers.com`, the origin already in its `connect-src`; `docs/embedding.md` and the changelog say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`src/lib/turnstile-frame.ts` existed to share the protocol between the challenge page and the hook. The page now lives in SahajCloud (sydevs/SahajCloud#1072), so the hook is its only consumer, and the module, its option and command types, the `challengeMessage` wrapper and the `unavailable` reason (never acted on) go. The test stub writes SahajCloud's wire format out literally, so the specs pin the contract rather than the hook's own constants. The `securitypolicyviolation` fast path and the `csp` verdict go too. Their advice was the `failed` advice word for word; what they bought was latency, so a host whose CSP refuses the frame now reaches the error screen at the 10 s handshake timeout instead of at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The probe resolves a boolean again, as before #343. Its two negative causes, a CSP that refuses SahajCloud's frame and a domain no client record lists, now share one console line naming both fixes; the browser logs a CSP refusal itself beside it, which tells them apart. That removes `TurnstileVerdict`, the guard's two-branch advice and SahajCloud's `reason` field with it (sydevs/SahajCloud#1072). The three "ignores a message from" cases become one table, and the probe spec loses an unused helper. The docs describe the arrangement as it is, leaving its history to the changelog entry: the embedding guide drops its migration notes ("used to", "has CHANGED", "is not the answer any more") and the troubleshooting table regains its base widths, and the environment, deployment and testing notes lose their accounts of what #259's key and guard used to be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The widget no longer tells an origin refusal from any other failure: its console names both fixes, and the browser logs a CSP refusal itself beside that line. So `reason` had no reader (sydevs/SahajAtlasWeb#355). The refusal still goes to `'*'` at once, which spares the widget its handshake timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… parent (#1076) * feat(atlas): serve the Turnstile challenge page, pinned to an allowed parent `GET /api/atlas/challenge?parent=<origin>&theme&language[&probe=1]` is the challenge the Atlas widget frames (sydevs/SahajAtlasWeb#343). Turnstile checks the hostname of the document that renders it, so serving it here means one hostname covers every partner page, and this service now holds both halves of the key: `TURNSTILE_SITE_KEY` joins the secret. The page renders only for a `parent` whose host is in some published client's `allowedDomains`, a first-party frontend (`SAHAJATLAS_URL`, `WEMEDITATE_WEB_URL` and their subdomains) or loopback, and posts every message to exactly that origin. The browser then delivers a token to no other parent, whatever the frame URL claims. A refusal posts `reason: 'origin'` so the widget fails at once and names the host to add. Matching is `originEnforcement.ts`'s own, with one deliberate difference: an empty `allowedDomains` contributes nothing here (question 1, option A), since this page is anonymous and "empty means any" would make it a token minter for every site. Not `frame-ancestors`, which the ticket proposed, for three reasons. Next 16 drops a route's own `Content-Security-Policy` once `next.config.mjs` has set one on `/:path*`, so the directive would never have been sent. It checks every ancestor, which breaks an iframe embed — the widget on the Atlas origin inside a partner page that no list names — although the token only ever reaches the immediate parent. And a frame refused that way is silent to its parent. The same Next behaviour means the admin's global `frame-src` reaches this page, so it now admits `challenges.cloudflare.com`. Closes #1072 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(atlas): refuse a Turnstile test site key in production The widget's release guard (sydevs/SahajAtlasWeb#259) failed a production build whose site key was one of Cloudflare's dummies. The key lives here now, and the tracked `.env` carries the always-passes test pair, so a production service that picked up both halves would run a captcha that accepts everything with nothing failing to say so. On production, the challenge page now answers that key with the same 503 refusal as a missing one, and logs it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(atlas): let a Workers preview frame the challenge `builtInParentHosts`' comment claimed the `*.` wildcard covered preview hosts. It does not: a SahajAtlasWeb or WeMeditateWeb PR preview lands on the Cloudflare account's Workers subdomain, under neither first-party host, and a preview build keeps `.env`'s `VITE_SAHAJCLOUD_URL`, so it frames production. Every Atlas preview was therefore refused `reason: 'origin'` and showed the captcha-blocked screen — in the one place a consumer PR is reviewable in a browser. The subdomain joins the built-in list, with loopback's reasoning: a page there is publishable only by someone who can deploy to our Cloudflare account, who already holds both halves of the Turnstile credential, so no token reaches a page in a stranger's visitor's browser. The wildcard's own claim is corrected to what it buys — each frontend's design playground. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjVcqUjptsqD2WQy41P * fix(atlas): read the parent origin through originOf `parentOrigin` restated `@/lib/utilities/url`'s rule — parse with `URL.parse`, and treat an opaque origin as none — which that module's docblock says is stated once. Two rules here are genuinely the endpoint's: http(s) only, and `origin === raw`, since the widget sends a bare `location.origin`. Those stay; the parse is the helper's now, and its docblock names its third consumer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjVcqUjptsqD2WQy41P * docs(atlas): name the preview subdomain in the challenge allowlist `docs/rules/api-clients.md` still described the allowlist as the clients' domains plus the frontends and loopback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjVcqUjptsqD2WQy41P * docs(atlas): repoint the challenge contract at the widget's hook SahajAtlasWeb#355 folds `src/lib/turnstile-frame.ts` into `src/hooks/use-turnstile.ts`, its only consumer once the challenge page moved here. The protocol is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(atlas): post the challenge refusal without a reason The widget no longer tells an origin refusal from any other failure: its console names both fixes, and the browser logs a CSP refusal itself beside that line. So `reason` had no reader (sydevs/SahajAtlasWeb#355). The refusal still goes to `'*'` at once, which spares the widget its handshake timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Devindra <devindra@Devindras-MacBook-Pro.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
cloud.sydevelopers.com, whatever page hosts the widget: one hostname, no per-partner Turnstile step.parent=<location.origin>. SahajCloud renders the challenge only for a domain some client record lists, and the page posts its token to that origin alone, so the token cannot reach a third party's frame. This closes the open-framing gap the review thread raised (fix(turnstile): partner hosts fail the captcha with 110200 (unknown domain) #355 (comment)).challenge.html,src/challenge.ts,VITE_TURNSTILE_SITE_KEYand One safe intake for everything visitors submit #259's release guard are gone. SahajCloud holds both halves of the key, and refuses a test key in production.main. The guard's one message names SahajCloud's origin underframe-srcand the page's host for the client record; the browser logs a CSP refusal itself beside it, which tells the two apart. A CSP-refused frame fails at the 10 s handshake timeout; a refused domain fails at once.allowfeatures, andresetaddressed to the frame's own origin.Merge together with sydevs/SahajCloud#1076, which serves the page this frames.
Change outline
Flow
Contract (
docs/embedding.md,CHANGELOG.md)Files
Test results
test:run(157 files, 1864 tests)pnpm build(+assert:css,assert:maps), nochallenge.htmlemitted.pnpm size:embed326.6 / 330 KiB,loader2.4 / 3.2 KiB.parent, the channel check or the origin check, accepting an empty token, and dropping either cause from the console advice each turn an assertion red.Manual verification (after both PRs deploy)
https://shrimataji.org/test/kn/meditation-centers-in-uk/: open a class and register with a test address. Expect success, notcaptcha_failed.https://wemeditate.com/map: solve and submit the report-issue form.cloud.sydevelopers.comonly. Workers Builds: deleteVITE_TURNSTILE_SITE_KEYfrom the production trigger.Notes for reviewer
localhost,*.sahajatlas.comand*.contact-c66.workers.devare always-allowed parents). That is safe: Ladle carries no API key, so a solved token reaches no write.allowedDomainsgets the captcha-blocked screen if its embed host is on no other client's list. That is #1072's question 1, answer A: an anonymous token page must not treat "empty" as "anyone".main.Closes #343
🤖 Generated with Claude Code