Skip to content

fix(turnstile): partner hosts fail the captcha with 110200 (unknown domain) - #355

Merged
Ardnived merged 16 commits into
mainfrom
claude/fix-343-turnstile-frame
Nov 3, 2026
Merged

Ardnived merged 16 commits into
mainfrom
claude/fix-343-turnstile-frame

Conversation

@sydevs-bot

@sydevs-bot sydevs-bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • The captcha renders in a frame served by SahajCloud (feat(atlas): serve the Turnstile challenge page, pinned to an allowed parent SahajCloud#1076), so Turnstile always sees cloud.sydevelopers.com, whatever page hosts the widget: one hostname, no per-partner Turnstile step.
  • The frame names its parent. The URL carries 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)).
  • The widget carries no Turnstile configuration. challenge.html, src/challenge.ts, VITE_TURNSTILE_SITE_KEY and 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.
  • The console names both fixes. The probe resolves a boolean, as on main. The guard's one message names SahajCloud's origin under frame-src and 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.
  • Kept from the earlier passes: the handshake timeout, the per-page probe memo, the six delegated allow features, and reset addressed to the frame's own origin.

Merge together with sydevs/SahajCloud#1076, which serves the page this frames.

Change outline

Flow

 host page (partner origin, or sahajatlas.com in an iframe embed)
   <sahaj-atlas> → useTurnstileGuard → probeTurnstile
-    <iframe src=sahajatlas.com/challenge.html?probe=1>
+    <iframe src=cloud.sydevelopers.com/api/atlas/challenge?parent=<location.origin>&probe=1>
   RegistrationForm / ReportIssueForm → useTurnstile
-    <iframe src=sahajatlas.com/challenge.html?theme&language>
-      page → postMessage(…, '*')                        # any framer got the token
+    <iframe src=cloud.sydevelopers.com/api/atlas/challenge?parent&theme&language>
+      SahajCloud: parent's host in some client's allowedDomains? (or first-party, or loopback)
+        yes → render; every message → postMessage(…, parent)   # browser delivers to that origin only
+        no  → postMessage({type:'error'}, '*')                 # widget fails at once

Contract (docs/embedding.md, CHANGELOG.md)

-frame-src   https://sahajatlas.com
+frame-src   https://cloud.sydevelopers.com          # already in the host's connect-src
 What you configure in the CMS instead
+| The domains you embed on | your client record — the API refuses reads elsewhere, the captcha renders nowhere else |
 Troubleshooting: "security check was blocked" → the console names frame-src, or the domain to list

Files

-challenge.html, src/challenge.ts               # the page lives in SahajCloud now
-scripts/turnstile-release-guard.{mjs,test.ts}  # no key in this build to guard
 src/hooks/use-turnstile.ts                     # frame, protocol and probe; ⚠ cross-repo sync pointer
 src/hooks/use-turnstile-guard.ts               # one console line naming both fixes
 src/test-support/turnstile-frame.ts            # SahajCloud's wire format, written out
 vite.config.ts, .ladle/vite.config.ts, public/_headers, .env   # back to main, minus the guard
 tests/smoke/embed.smoke.test.ts                # back to main, minus the site-key case

Test results

  • Lean gate: ✓ lint, typecheck, test:run (157 files, 1864 tests)
  • Build: ✓ pnpm build (+ assert:css, assert:maps), no challenge.html emitted. pnpm size: embed 326.6 / 330 KiB, loader 2.4 / 3.2 KiB.
  • Watched fail: dropping 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.
  • Not verified in a browser. A live solve needs the endpoint deployed, which happens when #1076 ships. SahajCloud's integration spec has since run on #1076's review container: 9 passed.

Manual verification (after both PRs deploy)

  1. https://shrimataji.org/test/kn/meditation-centers-in-uk/: open a class and register with a test address. Expect success, not captcha_failed.
  2. https://wemeditate.com/map: solve and submit the report-issue form.
  3. A page on a host no client record lists: expect the captcha-blocked screen, and the console naming that host.
  4. Turnstile dashboard: the hostname list becomes cloud.sydevelopers.com only. Workers Builds: delete VITE_TURNSTILE_SITE_KEY from the production trigger.

Notes for reviewer

  • Ladle and every Workers preview now frame production SahajCloud's real challenge (localhost, *.sahajatlas.com and *.contact-c66.workers.dev are always-allowed parents). That is safe: Ladle carries no API key, so a solved token reaches no write.
  • A client with an empty allowedDomains gets 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".
  • The per-edit width ceiling is back at 18, as on main.

Closes #343


🤖 Generated with Claude Code

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 sydevs-bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 question for the reviewer, one per thread.

🤖 Written by the sydevs autonomous loop — see what this is


Generated by Claude Code

Comment thread src/challenge.ts Outdated
}

function send(message: ChallengeEvent) {
window.parent.postMessage(challengeMessage(message), '*')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Go with B, but we don't want to merge this PR if it will be superseded by that ticket.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
sahajatlas-design 1451d06 Nov 03 2026, 09:43 PM

@sydevs-bot sydevs-bot added the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 1, 2026
@sydevs-bot

sydevs-bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 no session running
CI fix 1

recent dispatches
  • 22:33Z address-review · attempt 1 · session · done
  • 22:33Z address-review · attempt 1 · session
  • 21:11Z address-review · attempt 1 · session · done
  • 21:11Z address-review · attempt 1 · session
  • 02:27Z address-review · attempt 1 · session · done

Written by the sydevs dispatcher. Sessions never edit this comment.

@sydevs-bot

Copy link
Copy Markdown
Contributor Author

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:

  • sahajatlas.contact-c66.workers.dev → Cloudflare error code: 1042, and so does pr-344-sahajatlas…, an alias docs/testing.md records green on 2026-10-30. sahajatlas-design… serves 200 on the same subdomain.
  • The identical publish failure hit feat(map): brand-colour the map markers and cluster paints #353 at 20:05Z, a branch sharing nothing with this one. Last green CI: 19:10Z.
  • sahajatlas.com still answers 200 with our own _headers, so visitors are fine today.

No commit here fixes it — the app Worker is missing from the account CI reads, and versions upload → deploy would deploy every PR to production. Filed as #356.

This branch's own gate is green on 4833aac: Lint, Typecheck & Build and Dependency Review both passed.

🤖 Written by the sydevs autonomous loop — see what this is


Generated by Claude Code

@sydevs-bot sydevs-bot removed the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 1, 2026
@sydevs-bot sydevs-bot moved this from Working to Awaiting in SYDevelopers Technical Roadmap Nov 1, 2026
… rename)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sydevs-bot sydevs-bot moved this from Awaiting to Pending in SYDevelopers Technical Roadmap Nov 2, 2026
@sydevs-bot sydevs-bot added the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Pending to Working in SYDevelopers Technical Roadmap Nov 2, 2026

@sydevs-bot sydevs-bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🧐 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-fix Both form stories break on the design playground: /challenge.html is not in the Ladle build — use-turnstile.ts:66
  • should-fix The frame carries no allow, and § Permissions Policy was not revisited — use-turnstile.ts:78
  • consider challengeOrigin has one caller and re-derives a known origin — turnstile-frame.ts:76
  • consider reset posts 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

Comment thread src/hooks/use-turnstile.ts Resolved
Comment thread src/hooks/use-turnstile.ts Resolved
Comment thread src/lib/turnstile-frame.ts Resolved
Comment thread src/hooks/use-turnstile.ts Resolved
@sydevs-bot sydevs-bot added bot:working Lock: a cloud session is running on this item. Removed when it ends. and removed bot:working Lock: a cloud session is running on this item. Removed when it ends. labels Nov 2, 2026
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
@sydevs-bot

Copy link
Copy Markdown
Contributor Author

Four critic threads: three adopted, one rebutted. The question thread on src/challenge.ts still waits on you.

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

@sydevs-bot
sydevs-bot requested a review from Ardnived November 2, 2026 03:11
@sydevs-bot
sydevs-bot enabled auto-merge November 2, 2026 03:11
@sydevs-bot sydevs-bot moved this from Working to Awaiting in SYDevelopers Technical Roadmap Nov 2, 2026
Comment thread src/hooks/use-turnstile.ts Resolved
Comment thread src/lib/turnstile-frame.ts Resolved
Comment thread src/challenge.ts Outdated
}

function send(message: ChallengeEvent) {
window.parent.postMessage(challengeMessage(message), '*')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

@sydevs-bot sydevs-bot added the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Awaiting to Working in SYDevelopers Technical Roadmap Nov 2, 2026
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
@sydevs-bot

Copy link
Copy Markdown
Contributor Author

Two reviewer threads adopted. The question thread has a fourth option now and still waits on you.

Adopted

Every ⚠ and #NNN survives compressed, per docs/rules/code-comments.md. No spec was edited, which is the test of "preserving behaviour".

Asked

Merged origin/main in cleanly, no overlapping file. Lean gate green: lint, typecheck, 1952 tests. The body's "two derivations" note is corrected.

🤖 Written by the sydevs autonomous loop — see what this is


Generated by Claude Code

@sydevs-bot sydevs-bot removed the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Working to Awaiting in SYDevelopers Technical Roadmap Nov 2, 2026
@sydevs-bot sydevs-bot added the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Awaiting to Working in SYDevelopers Technical Roadmap Nov 2, 2026
@sydevs-bot

Copy link
Copy Markdown
Contributor Author

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, docs/embedding.md — stand wherever the page is served.

The cost that is not recoverable: a host that sends a CSP changes frame-src twice, from sahajatlas.com to SahajCloud's origin. sydevs/SahajCloud#1072 notes an option keeping that value stable, to weigh before building.

Base is current, no merge needed. No code change, so no gate run. ## Notes for reviewer records the decision.
🤖 Written by the sydevs autonomous loop — see what this is


Generated by Claude Code

@sydevs-bot sydevs-bot removed the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Working to Awaiting in SYDevelopers Technical Roadmap Nov 2, 2026
…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>
Devindra and others added 2 commits November 3, 2026 13:15
`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>
Ardnived pushed a commit to sydevs/SahajCloud that referenced this pull request Nov 3, 2026
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>
Ardnived added a commit to sydevs/SahajCloud that referenced this pull request Nov 3, 2026
… 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>
@Ardnived
Ardnived merged commit 494a0d3 into main Nov 3, 2026
11 checks passed
@Ardnived
Ardnived deleted the claude/fix-343-turnstile-frame branch November 3, 2026 22:20
@sydevs-bot sydevs-bot moved this from Awaiting to Done in SYDevelopers Technical Roadmap Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

fix(turnstile): partner hosts fail the captcha with 110200 (unknown domain)

3 participants