fix(client): drop invalid Turnstile size + CSP allowlist Lato + pagehide widget teardown#120
Merged
Merged
Conversation
…execute' acquireTurnstileToken passed `size: 'invisible'` to api.render(). Per CF docs, `size` accepts compact|flexible|normal only; 'invisible' is not a valid value and Turnstile throws "Uncaught TurnstileError: Invalid value for parameter 'size'". The widget enters a stuck state, and every subsequent execute() call reports "Call to execute() on a widget that is already executing" — masking the #119 reset+execute fix. Invisible behavior is sitekey-mode (set in the CF dashboard), not a render-time param. Drop the invalid `size` value; the off-screen container CSS (position:absolute;left:-9999px) keeps the widget visually hidden regardless of mode. Also add `execution: 'execute'` so the challenge defers to our explicit api.execute() call instead of starting on render (the default 'render' execution races acquireTurnstileToken's promise resolution). Type definition tightened to match docs: size enum now `'compact'|'flexible'|'normal'`, new optional `execution` field. Surfaced after #118 corrected the production sitekey and the form started producing real Turnstile validation. Pre-#118 the wrong sitekey errored before size validation; post-#118 the size error fires first.
Two follow-ups to the size:'invisible' fix (3ff7b7a), surfaced during staging validation: CSP for Turnstile's Lato stylesheet. Even with the sitekey configured as Invisible mode in the CF dashboard, the Turnstile bootstrap injects `<link rel=stylesheet href="https://fonts.googleapis.com/css?family=Lato...">` into the host document (defensive UI prep in case a challenge elevates to a visible UI). The CSS in turn loads font files from fonts.gstatic.com. Allowlist both origins on style-src + font-src in both CSP_HTML (src/worker/headers.ts) and LIVE_SCORE_CSP (src/worker/score/summary-render.ts). pagehide widget teardown. After the size fix the homepage form worked on first submit, but a back-button bfcache restore re-bootstrapped the existing Turnstile widget DOM and re-injected the Lato stylesheet, triggering the CSP violation again (and a "preload but not used" warning for the challenge platform's cmg resource). On pagehide, api.remove(widgetId) the existing widget and clear module-scope state; the next acquireTurnstileToken renders fresh. Validated on staging at Worker version c6ab5306-b238-4e07-b41b-472858261c15: first submit succeeds, back-button, second submit succeeds, zero CSP violations, zero Turnstile errors.
16 tasks
brettdavies
added a commit
that referenced
this pull request
May 26, 2026
…rl without hint (#121) ## Summary Fixes a production-affecting bug where `/api/score` returned no `share_url` for any github-url paste without a curated discovery hint (e.g., `https://github.com/sharkdp/hexyl`, `https://github.com/o2sh/onefetch`). Users got a one-shot inline scorecard with no shareable URL, and `/score/live/<binary>` 404'd even though the DO had written the cache entry to R2. The handler now derives `share_url` from the discovered `spec.binary` on both the post-discovery cache_post tier and the live-success branch, keyed identically to the DO's cache write. Reproduced on staging Worker `c6ab5306-b238-4e07-b41b-472858261c15` (PR #120 branch), fix verified on staging Worker deployed from this branch via workflow_dispatch run [26426837021](https://github.com/brettdavies/agentnative-site/actions/runs/26426837021). ## Root cause The 2026-05-20 discovery-move (PR #100, U8) lifted binary resolution from the DO up to the Worker. Pre-move, the DO owned both discovery AND the scorecard run, so `share_url` could be derived at handler time only when the binary was knowable from the input alone (install-command spec or a hinted github-url). Discovery moving up to the Worker created the resolved `spec.binary` one tier earlier in the pipeline, but the share-URL derivation stayed pinned to the pre-discovery shape and never picked up the new value. The rest of the discovery-move audit surfaced no other stranded derivations. ## Changelog ### Fixed - `/api/score` now returns `share_url: "/score/live/<binary>"` for github-url pastes without a curated hint, after the live discovery resolves the binary. Previously these requests returned a scorecard inline but no shareable URL, leaving the R2 cache entry unreachable. - `share_url` now omits unshareable binaries (uppercase, underscore, period, or leading hyphen in the discovered binary). Previously these would have minted URLs the `/score/live/<binary>` route refuses to serve. ## Type of Change - [x] `fix`: Bug fix (non-breaking change which fixes an issue) - [x] `test`: Adding or updating tests - [x] `refactor`: Code refactoring (no functional changes) ## Related Issues/Stories - Story: n/a - Issue: n/a - Architecture: n/a - Related PRs: #115 (live-scoring v3 launch where the gap shipped), #120 (Turnstile fix on the Worker version where the bug was reproed on staging) ## Testing - [x] Unit tests added/updated - [x] Integration tests added/updated - [x] Manual testing completed - [x] All tests passing **Test Summary:** - New file `tests/score-handler-share-url-post-discovery.test.ts`: 9 tests written tests-first (4 happy-path / drift-safety for github-url without hint, 4 red-team slug-shape tests, 1 branch-scoped no-op). All 4 happy-path tests failed before the fix landed; all 9 pass after. - Extended `tests/score-registry-lookup.test.ts`: 6 unit tests for the new `deriveShareBinaryFromSpec()` helper covering all InstallSpec variants, plus a regex-source equality invariant pinning that `SHARE_URL_BINARY_RE` and the route's `BINARY_SLUG_RE` stay byte-identical. - Full suite: 755/755 pass, 0 fail. - Staging dogfood: deployed via workflow_dispatch, user confirmed the share-URL UX works end-to-end on the two repro inputs (`sharkdp/hexyl`, `o2sh/onefetch`). ## Files Modified **Modified:** - `src/worker/score/registry-lookup.ts`: added `SHARE_URL_BINARY_RE` constant, `deriveShareBinaryFromSpec(spec)` post-discovery helper, and a `safeShareBinary()` slug-shape gate. Extended `deriveShareBinary` to gate through `safeShareBinary` so pre-discovery callers also refuse unshareable binaries. - `src/worker/score/handler.ts`: post-discovery cache_post tier (step 6.5) and live-success branch now mint `share_url` via the new `shareUrlForSpec(spec)`, keyed to `spec.binary` (the same value `do.ts:writeCacheBestEffort` writes the R2 cache under). Pre-discovery cache_pre tier still uses `shareUrlForInput` because the binary is by definition derivable from input there. - `src/worker/score/summary-render.ts`: `BINARY_SLUG_RE` re-pointed at `SHARE_URL_BINARY_RE` so the route slug regex and the handler's mint-gate regex are one source of truth. The slug regex literal stays in `registry-lookup.ts`; the equality is asserted by a unit test. - `tests/score-registry-lookup.test.ts`: six new unit tests for `deriveShareBinaryFromSpec` plus one regex-source invariant test. **Created:** - `tests/score-handler-share-url-post-discovery.test.ts`: 9 tests covering the bug plus red-team slug-shape gates. **Renamed:** - None. **Deleted:** - None. ## Key Features - Eliminates the silent-fail UX for any github-url paste of a CLI not in the curated 96 hint set. The long tail of CLIs (most of them, by definition) now gets the same share-URL surface as curated tools. - Slug-shape gate at mint time prevents a future regression where a discovered binary with characters the route rejects (`MyTool`, `my_tool`, `tool.js`) ships a URL that 404s downstream. - Regex-source equality test makes the route ↔ handler invariant unfakeable at refactor time. ## Benefits - Restores the v3 live-scoring product surface to the experience the launch documented: paste a github URL, get a shareable result page. - No new dependencies, no new bindings, no migration. Same wrangler config as `dev`; safe to land independently of any infra work. ## Breaking Changes - [x] No breaking changes - [ ] Breaking changes described below: ## Deployment Notes - [x] No special deployment steps required Standard `dev`-squash-merge then production cherry-pick when the next release window opens. No env vars added or removed; no migration entries added (DO migrations stay untouched). ## Checklist - [x] Code follows project conventions and style guidelines - [x] Commit messages follow Conventional Commits - [x] Self-review of code completed - [x] Tests added/updated and passing - [x] No new warnings or errors introduced - [x] Changes are backward compatible ## Additional Context Three commits in tests-first order: `6be3866` (failing tests), `6560587` (fix to green), `089a264` (refactor to collapse the duplicate slug regex).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three fixes that surfaced sequentially while validating PR #119's reset+execute fix on staging. Validated end-to-end at staging Worker version
c6ab5306-b238-4e07-b41b-472858261c15.Commit 1 (
3ff7b7a), drop invalidsize: 'invisible'+ addexecution: 'execute'.acquireTurnstileTokenpassedsize: 'invisible'toapi.render(). Per CF docs,sizeacceptscompact | flexible | normalonly;invisiblethrowsUncaught TurnstileErrorand puts the widget in a stuck "executing" state that masks the #119 reset+execute fix. Invisible behavior issitekey-mode (set in CF dashboard), not a render-time argument. Drop the invalid value; the off-screen container CSS keeps the widget visually hidden. Addexecution: 'execute'so the challenge defers to our explicitapi.execute()instead of starting on render.Commit 2 (
1ff0b01), allow Lato in CSP + tear down widget onpagehide. Even with thesitekeyconfigured asInvisiblemode, Turnstile's bootstrap injects<link rel=stylesheet href="https://fonts.googleapis.com/css?family=Lato...">into the host document (defensive UI prep). Our CSP blocks it. Allowlisthttps://fonts.googleapis.comonstyle-srcandhttps://fonts.gstatic.comonfont-srcin bothCSP_HTML(src/worker/headers.ts) andLIVE_SCORE_CSP(src/worker/score/summary-render.ts). Separately, onpagehide, callapi.remove(widgetId)and clear module-scope state so abfcacherestore can't re-bootstrap a half-dead widget and re-inject the Lato stylesheet.Changelog
Fixed
/api/scorehomepage form on anc.dev now executes Turnstile cleanly: zeroUncaught TurnstileError, zero "already executing" warnings, zero CSP violations on initial load ORbfcacherestore (back-button to homepage from a result page).Changed
https://fonts.googleapis.comonstyle-srcandhttps://fonts.gstatic.comonfont-src(bothCSP_HTMLandLIVE_SCORE_CSP). Required for Turnstile's defensive Lato bootstrap even on Invisible-modesitekeys.sizeis now'compact' | 'flexible' | 'normal'; new optionalexecutionfield.Type of Change
fix: Bug fix (non-breaking change which fixes an issue)Related Issues/Stories
size:'invisible'invalid value; fixing that unmasked the Lato CSP gap and thebfcachestale-widget. All three now clean.Testing
Test Summary:
bun test: 737 pass / 0 fail.bun run lint: clean.bun run build: clean.dist/js/live-score.jsbundle containsexecution:'execute'and thepagehidehandler; nosize:'invisible'.c6ab5306-b238-4e07-b41b-472858261c15(deployed from this branch viabun x wrangler deploy --env staging):ripgrep) round-trips, redirects to/score/ripgrep. Zero console warnings.bfcacherestore.Files Modified
Modified:
src/client/live-score.ts: dropsize: 'invisible'fromapi.render(); addexecution: 'execute'; tightenTurnstileApitype; addpagehidelistener that removes the widget + clears module-scope state.src/worker/headers.ts: extendCSP_HTMLstyle-srcandfont-srcto allow Google Fonts origins (Turnstile bootstrap requirement).src/worker/score/summary-render.ts: same CSP extension onLIVE_SCORE_CSPfor/live-score/<binary>pages.Created:
Renamed:
Deleted: