Skip to content

fix(client): drop invalid Turnstile size + CSP allowlist Lato + pagehide widget teardown#120

Merged
brettdavies merged 2 commits into
devfrom
fix/turnstile-invalid-size-param
May 25, 2026
Merged

fix(client): drop invalid Turnstile size + CSP allowlist Lato + pagehide widget teardown#120
brettdavies merged 2 commits into
devfrom
fix/turnstile-invalid-size-param

Conversation

@brettdavies
Copy link
Copy Markdown
Owner

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 invalid size: 'invisible' + add execution: 'execute'. acquireTurnstileToken passed size: 'invisible' to api.render(). Per CF docs, size accepts compact | flexible | normal only; invisible throws Uncaught TurnstileError and puts the widget in a stuck "executing" state that masks the #119 reset+execute fix. Invisible behavior is sitekey-mode (set in CF dashboard), not a render-time argument. Drop the invalid value; the off-screen container CSS keeps the widget visually hidden. Add execution: 'execute' so the challenge defers to our explicit api.execute() instead of starting on render.

Commit 2 (1ff0b01), allow Lato in CSP + tear down widget on pagehide. Even with the sitekey configured as Invisible mode, 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. Allowlist https://fonts.googleapis.com on style-src and https://fonts.gstatic.com on font-src in both CSP_HTML (src/worker/headers.ts) and LIVE_SCORE_CSP (src/worker/score/summary-render.ts). Separately, on pagehide, call api.remove(widgetId) and clear module-scope state so a bfcache restore can't re-bootstrap a half-dead widget and re-inject the Lato stylesheet.

Changelog

Fixed

  • /api/score homepage form on anc.dev now executes Turnstile cleanly: zero Uncaught TurnstileError, zero "already executing" warnings, zero CSP violations on initial load OR bfcache restore (back-button to homepage from a result page).

Changed

  • CSP allowlist for https://fonts.googleapis.com on style-src and https://fonts.gstatic.com on font-src (both CSP_HTML and LIVE_SCORE_CSP). Required for Turnstile's defensive Lato bootstrap even on Invisible-mode sitekeys.
  • TurnstileApi type tightened to match docs: size is now 'compact' | 'flexible' | 'normal'; new optional execution field.

Type of Change

  • fix: Bug fix (non-breaking change which fixes an issue)

Related Issues/Stories

Testing

  • Unit tests added/updated
  • All tests passing

Test Summary:

  • bun test: 737 pass / 0 fail.
  • bun run lint: clean.
  • bun run build: clean. dist/js/live-score.js bundle contains execution:'execute' and the pagehide handler; no size:'invisible'.
  • Manual on staging at Worker version c6ab5306-b238-4e07-b41b-472858261c15 (deployed from this branch via bun x wrangler deploy --env staging):
    • First submit (ripgrep) round-trips, redirects to /score/ripgrep. Zero console warnings.
    • Back-button to homepage. Zero CSP violations on bfcache restore.
    • Second submit succeeds the same as the first.

Files Modified

Modified:

  • src/client/live-score.ts: drop size: 'invisible' from api.render(); add execution: 'execute'; tighten TurnstileApi type; add pagehide listener that removes the widget + clears module-scope state.
  • src/worker/headers.ts: extend CSP_HTML style-src and font-src to allow Google Fonts origins (Turnstile bootstrap requirement).
  • src/worker/score/summary-render.ts: same CSP extension on LIVE_SCORE_CSP for /live-score/<binary> pages.

Created:

  • None.

Renamed:

  • None.

Deleted:

  • None.

…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.
@brettdavies brettdavies merged commit 2df0d06 into dev May 25, 2026
2 checks passed
@brettdavies brettdavies deleted the fix/turnstile-invalid-size-param branch May 25, 2026 07:33
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant