Skip to content

Assessment endpoint: Turnstile, validation and delivery #28

Closed

Description

@Abuubkar

Parent

Spec: #20

What to build

The endpoint behind the Assessment Form: a single server route in the same Worker that serves the site, which verifies the visitor is human, revalidates everything, and delivers the submission to the Inbox with the visitor's address as reply-to.

Turnstile runs invisible, on submit — never gating the button. Tokens are single-use with a 300-second window, so a retry after failure needs a fresh token; reusing the original fails as a duplicate and looks like a different bug.

The server revalidates independently. Client validation is a convenience, never a guard.

The response contract is a discriminated union the UI maps directly onto its states:

type Result =
  | { ok: true }
  | { ok: false; error: 'validation'; fields: Record<string, string> }
  | { ok: false; error: 'challenge' | 'rate' | 'delivery' };

Email sends from an Email Routing subdomain so the apex MX stays with Google.

Rate limiting is deliberately not in code — it is WAF configuration, so the Worker stays stateless.

The Worker logs field names and outcomes only, never values.

Acceptance criteria

  • A server route accepts POST with FormData and has no component, so it is excluded from prerendering
  • Honeypot non-empty is rejected as a validation failure
  • The Turnstile token is verified server-side; a missing or invalid token returns the challenge error
  • Verification checks the response hostname against the expected host
  • Every field is revalidated and length-capped server-side; unknown fields are rejected
  • Success sends via the email binding with reply-to set to the visitor's address
  • The subject identifies the practice or the sender; the body lists submitted fields, omitting empties, with honeypot and token stripped
  • All five result shapes return their documented status codes with Cache-Control: no-store
  • The UI maps each result onto the matching state from ticket 8
  • A retry after a failure requests a fresh Turnstile token
  • No message body, email address or field value is ever logged
  • All paths are exercisable locally with test keys and a simulated binding, with no Cloudflare account
  • An empty client sitekey does not disable server-side verification

Blocked by

Activity

  1. changed the title [-]Assessment section and form UI[/-] [+]Assessment endpoint: Turnstile, validation and delivery[/+] on Sep 5, 2026
  2. self-assigned this
    on Sep 5, 2026
  3. Abuubkar commented on Sep 5, 2026

    @Abuubkar
    OwnerAuthor

    Done on branch ticket/28-endpoint.

    Verified against the real Worker

    Every path exercised under wrangler/Miniflare with Turnstile test keys — no Cloudflare account:

    Case Result
    honeypot filled 400 {"error":"validation"}
    no Turnstile token 403 {"error":"challenge"}
    token + missing name 400 {"fields":{"name":"required"}}
    token + bad email 400 {"fields":{"email":"invalid"}}
    token + 250-char name 400 {"fields":{"name":"too_long"}}
    fully valid 502 {"error":"delivery"}

    That last one is correct: no EMAIL binding exists locally, so delivery genuinely fails and the UI offers the mailto fallback rather than faking success. Every response carries cache-control: no-store.

    Three findings

    1. globalThis.env is not populated in Start server code. Reading it yields an empty secret, which short-circuits every submission to challenge — indistinguishable from a real Turnstile failure. cloudflare:workers is the accessor, exactly as the #4 research documented; I had reached for the obvious-looking global first.

    2. wrangler types breaks client typing. It emits a global Worker environment that overrides DOM lib types project-wide — document.head.append() stopped typechecking in client code. Replaced with a local declaration of just the four bindings the route needs, so the two environments cannot collide. Worth knowing before someone runs wrangler types again.

    3. Validation reported the wrong reason. An over-long name came back as required rather than too_long, because the required check overwrote the length flag — sending the visitor to look for an empty field. Fixed.

    Also worth noting: a Miniflare ERR_RUNTIME_FAILURE mid-testing turned out to be leftover runtime state from repeatedly killing preview servers, not a code fault. A clean rm -rf .wrangler restart resolved it.

    Client

    Turnstile loads on first form interaction, not page load (issue #18), and the widget is reset after every attempt — tokens are single-use with a 300-second window, so a retry needs a fresh one.

  4. Abuubkar commented on Sep 5, 2026

    @Abuubkar
    OwnerAuthor

    Done. All six paths verified against the real Worker; three bugs found doing so.

  5. added a commit that references this issue on Sep 5, 2026
    59a6356
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

ready-for-agentSpec is complete and ready for an agent to implement

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions