@weatherboard/forms
v0.2.2
Published
A drop-in contact and waitlist form for client sites: a headless React hook, a web-standard forwarding handler with a Next adapter, and validation generated from a pinned form definition. Ships no styles.
Maintainers
Readme
@weatherboard/forms
The drop-in contact and waitlist form for a client site (A-1161; this package is slice S6, A-1168). Three parts:
- a headless React hook,
useWeatherboardForm, that holds the values, shows the fields the chosen audience reveals, validates with the same function intake validates with, and hands back ids,aria-*wiring and handlers for the site's own design-system components to render; - a web-standard forwarding handler,
createFormsHandler, that a site mounts at/api/forms/<form_key>and that forwards a same-origin browser POST to intake, server to server, under intake's Wire contract v1 (apps/intake/README.md); - a thin Next adapter and an on-interaction Turnstile loader.
It ships no styles and no components. The client's design system renders the form, so the design-system gate and the accessibility audit apply to it unchanged. The single inline style in the package is on the honeypot input, which it moves off-screen: that is what the honeypot is for, not how it looks.
It has no runtime dependencies. React is an optional peer, needed only by
./react.
The design is docs/plans/a-1161-forms.md (§2 shape, §3.4 abuse, §3.7 logs,
§7 definitions, §8 reaching hundreds of sites). The definition format itself
is documented in src/definition/README.md.
Install
pnpm add @weatherboard/forms| entry point | what | runs in |
| --- | --- | --- |
| @weatherboard/forms | the definition format and the starters, the browser contract, and the pure form core (visibleFields, drawnFields (the order to draw and focus fields in, consent last), submissionValues, validateForm, default messages) | anywhere |
| @weatherboard/forms/react | useWeatherboardForm | the browser |
| @weatherboard/forms/server | createFormsHandler, vercelClientIp | the site's server |
| @weatherboard/forms/next | createFormsRoute | a Next route handler |
| @weatherboard/forms/turnstile | loadTurnstile | the browser |
Environment
| name | where | what |
| --- | --- | --- |
| FORMS_INGEST_URL | server only | intake's base URL, https://… (plain http only for a loopback host). The handler appends v1/submissions, keeping any path: https://host/forms posts to https://host/forms/v1/submissions. |
| FORMS_INGEST_TOKEN | server only, secret | the project's ingest token: 43 characters of base64url. Never sent to the browser, never logged. |
| PUBLIC_TURNSTILE_SITE_KEY | browser | the project's Turnstile site key. Public by design. |
Spin-up writes all three (plan §8, the forms-provision step). An unset or
malformed server variable is refused by name, at the first submission:
the site's log carries {"event":"could_not_answer","setting":"FORMS_INGEST_TOKEN"}
and the browser gets unavailable. Nothing fails at build or import time.
In Next, a client component sees a variable only if it is NEXT_PUBLIC_* or
named in next.config.ts's env block. The scaffold uses the PUBLIC_
prefix and the env block, so either add PUBLIC_TURNSTILE_SITE_KEY there
or read it in a server component and pass it down as a prop.
Next: the route
src/app/api/forms/[form_key]/route.ts:
import { createFormsRoute } from "@weatherboard/forms/next";
// Every form this site serves, each pinned to the definition VERSION it renders.
export const { POST } = createFormsRoute({ forms: { contact: 1, waitlist: 1 } });createFormsRoute reads FORMS_INGEST_URL and FORMS_INGEST_TOKEN from
process.env on every request. It accepts every createFormsHandler option
except the two it reads (below), plus env to read them from somewhere else.
Astro: the same handler
No Astro dependency: the handler is (Request, context?) => Promise<Response>,
which is an Astro endpoint with one line around it. src/pages/api/forms/[form_key].ts:
import type { APIRoute } from "astro";
import { createFormsHandler } from "@weatherboard/forms/server";
export const prerender = false;
const FORMS = { contact: 1, waitlist: 1 };
// Read per request from process.env, not import.meta.env: Vite replaces
// `import.meta.env.*` at build time, which would write the token into the
// build output. Astro knows the visitor's address better than a header does,
// so it is passed in.
export const POST: APIRoute = ({ request, clientAddress }) =>
createFormsHandler({
ingestUrl: process.env.FORMS_INGEST_URL,
ingestToken: process.env.FORMS_INGEST_TOKEN,
forms: FORMS,
})(request, { clientIp: clientAddress });The endpoint needs server output (an adapter), because it runs per request.
createFormsHandler(options)
| option | default | |
| --- | --- | --- |
| ingestUrl, ingestToken | — | FORMS_INGEST_URL, FORMS_INGEST_TOKEN; may be undefined, refused at use |
| forms | — | { [form_key]: version }. A key not here is 404 not_found; Object.hasOwn, so constructor is not a form |
| allowedOrigins | an Origin whose host equals the request's Host header | an exact list of origins allowed to post; set it when a proxy in front of the app rewrites Host |
| clientIp | vercelClientIp | (request) => string \| null; see "The visitor's address" |
| timeoutMs | 10,000 | how long to wait for intake |
| fetch, log | global fetch; one JSON line per event on stdout | injected in tests |
The order, each step running only if every earlier one passed:
| # | check | answer |
| --- | --- | --- |
| 1 | method is POST | 405 method_not_allowed, allow: POST |
| 2 | the last path segment is a form in forms | 404 not_found |
| 3 | same origin: Origin's host equals the Host header (not request.url, which a framework may build with a different host — measured on next start), or with no Origin, Sec-Fetch-Site: same-origin | 403 forbidden |
| 4 | content-type is JSON | 415 unsupported_media_type |
| 5 | body at most 61,440 bytes, counted as it is read | 413 too_large |
| 6 | body is the browser contract below, and nothing else | 400 bad_request |
| 7 | honeypot empty | a filled one gets the success answer, byte for byte, and nothing is forwarded |
| 8 | a Turnstile token is present | 403 verification_failed |
| 9 | FORMS_INGEST_URL, FORMS_INGEST_TOKEN and the visitor's address resolve | 503 unavailable, the variable named in the log |
| 10 | forward to intake, map its answer | see below |
What is forwarded is built, never copied. The handler writes exactly four
headers: authorization: Bearer <FORMS_INGEST_TOKEN>, content-type,
accept and x-forms-client-ip. No cookie, origin, referer or any other
incoming header crosses. The runtime's own fetch adds its transport headers
on top: MEASURED 2026-09-27 at a fake intake behind a next start of a
scaffold-generated app on Node 22, those were accept-encoding,
accept-language, connection, content-length, host, sec-fetch-mode
and user-agent, and no origin or cookie. The body is a fresh Wire contract v1
envelope: form_key from the path, definition_version from forms (never
from the browser), and the browser's values, turnstile_token and
source_path. Redirects are not followed (redirect: "manual"), so the
bearer token cannot be carried to a host nobody configured.
Intake's answer, mapped. Only what a visitor can act on keeps its identity:
| intake | browser |
| --- | --- |
| 202 { ok: true } | 200 { ok: true } |
| 422 invalid_values | 422 invalid with fields: each { field, code } relayed only if the code is known and the field is a key the browser itself sent (else field: null) |
| 429 rate_limited | 429 rate_limited, with retry_after and retry-after when intake gave a usable one |
| 403 turnstile_failed | 403 verification_failed |
| 413 payload_too_large | 413 too_large |
| anything else — unauthorized, project_not_served, unknown_definition, definition_invalid, project_not_provisioned, turnstile_unavailable, could_not_answer, unavailable, internal, client_ip_invalid, origin_forbidden, an unknown code, an unreadable body, a redirect, a timeout, a network failure | 503 unavailable, with intake's status and code in the site's log |
Every response is JSON, cache-control: no-store, with no CORS header.
The browser contract
POST /api/forms/<form_key>
content-type: application/json
{ "values": { "<field>": "…" | true | false, … },
"turnstile_token": "…",
"honeypot": "", // optional
"source_path": "/contact" } // optional: a path, no ? or #, or nullAnswers: { "ok": true }, or { "ok": false, "error": <code> } plus fields
(with invalid) or retry_after (with rate_limited). The codes are
BROWSER_ERROR_CODES: invalid, verification_failed, rate_limited,
too_large, bad_request, forbidden, not_found, method_not_allowed,
unsupported_media_type, unavailable. They are stable; a new one is a major
version.
useWeatherboardForm(options)
"use client";
import type { FormDefinition } from "@weatherboard/forms";
import { useWeatherboardForm } from "@weatherboard/forms/react";
// `definition: FormDefinition` is the project's PINNED definition, a copy of the
// published row passed down from a server component: the version the route's
// `forms` names.
const form = useWeatherboardForm({
formKey: "contact",
definition,
turnstileSiteKey, // PUBLIC_TURNSTILE_SITE_KEY
});
<form {...form.formProps}>
{form.fields.map((field) => /* your component, given field.inputProps / value / setValue / onBlur / errorMessage */)}
<div aria-hidden="true"><input {...form.honeypotProps} /></div>
<div ref={form.turnstileRef} />
<p id={form.formErrorId} role="status">{form.formError?.message}</p>
<YourButton type="submit" disabled={form.status === "submitting"}>Send</YourButton>
</form>| option | |
| --- | --- |
| formKey, definition, turnstileSiteKey | required |
| endpoint | default /api/forms/<formKey> |
| fieldMessages, formMessages | override any default message; {label} in a field message is the field's label |
| verificationTimeoutMs | how long a submit waits for a Turnstile token; default 15,000 |
| onSuccess | called once on a stored (or honeypotted) submission |
What it returns:
fields: the visible fields, in definition order (the common group, then the group the audience discriminator's current value reveals). Each hasname,type,label,required,maxLength,options,value,setValue,onBlur,id,errorId,error(aValueErrorCode),errorMessage, andinputProps:{ id, name, required, aria-required, aria-invalid, aria-describedby }, wherearia-describedbynameserrorIdwhenever there is an error. Give the element that showserrorMessagethe iderrorId.field(name)looks one up.- Switching the audience hides the other group's fields; what was typed in them is kept (switching back restores it) but never sent, because intake refuses a hidden group's non-empty field.
- Errors appear once a field has been left (
onBlur) or a submit was tried, never while the visitor is still typing a first time. A server field error is shown on its field and cleared when that field is edited. On a refused submit, focus moves to the first field in error. status:idle,submitting,succeeded,failed.formError:{ code, message }from the route's code, ornetworkwhen no answer came back.formProps:noValidate,onSubmit,onFocus,onInput, andaria-describedbypointing atformErrorIdwhen there is a form error.submit()andreset()for a component that submits by itself.
Client validation is the server's validator. validateForm runs S4's
validateSubmission — the function intake runs — on exactly the values
submissionValues will send. The shared fixtures in
src/testing/value-fixtures.ts are run through both the client and, via the
real forwarding handler, a stand-in intake running the server validator, and
must agree.
Turnstile loads on interaction
formProps.onFocus and onInput load Cloudflare's script on the first
focus or keystroke inside the form, never at render, so a visitor who does
not touch the form downloads nothing from Cloudflare. The script is added
once per document from exactly
https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit —
Cloudflare's docs say it must not be proxied or cached — and the widget is
rendered into turnstileRef with appearance: "interaction-only", action
set to the form key, and response-field: false (the token travels in the
JSON body, not a hidden input). A token is single-use: the widget is reset
after every answer. The widget runs in a container the page keeps hidden
until Turnstile says it needs the visitor: challengeVisible is true from its
before-interactive-callback until its after-interactive-callback (or an
answer, an error, an expiry or a timeout). Hidden with display: none, the
widget still renders and still produces a token. A site with a Content-Security-Policy must allow
https://challenges.cloudflare.com in script-src and frame-src.
A browser Turnstile cannot run in
When Cloudflare reports the visitor's browser unsupported (its
unsupported-callback), the hook returns unsupportedMessage, a sentence to
show by the submit button so the visitor learns before trying to submit. It is
null otherwise. The shared Form in @weatherboard/ui renders it as an alert
after Send. The default is "This form can't run in your browser. Please email us
instead."; pass the unsupportedMessage option to give a site's own contact
line:
const form = useWeatherboardForm({
formKey: "contact",
definition,
turnstileSiteKey,
unsupportedMessage: "This form can't run in your browser. Please email [email protected] instead.",
});The same report releases a submit that was waiting for a token, as
verification_failed, and clears any token and hides a visible challenge. A
later submit then fails the same way at once, unless the widget has since issued
a token, which is then used.
The visitor's address
Intake keys its rate limit on the address the site sends in
x-forms-client-ip, and passes it to Turnstile. The default resolver,
vercelClientIp, reads:
x-vercel-forwarded-for, when present;- else the first entry of
x-forwarded-for.
That is right on Vercel only. Vercel's request-headers page (read
2026-09-27) says Vercel overwrites x-forwarded-for with the client's public
address and does not forward external values, "to prevent IP spoofing", and
that x-vercel-forwarded-for carries the same value even when a proxy sits in
front of Vercel. Anywhere else, pass clientIp: (request) => … naming the
header your own proxy sets, or pass the per-request context.clientIp (Astro's
clientAddress, above). A request with no usable address is refused
unavailable and logged client_ip_unresolved; it is not forwarded without
one.
What this does not protect against
Scoped to what the code here does, not to the system (for that, see the plan §3.8 and intake's README):
- A visitor who bypasses the browser. The same-origin check (
Origin, orSec-Fetch-Site) stops another site's page from posting through a visitor's browser. A script calling the route directly can send anyOriginit likes; what it still has to pass is Turnstile and intake's rate limit. - The address header off Vercel. Behind any other host, with the default
resolver, a client can choose its own
x-forwarded-forand spread its submissions across rate-limit keys. ConfigureclientIp. - A proxy in front of Vercel that rewrites
x-forwarded-foris handled by preferringx-vercel-forwarded-for; a proxy that also rewrites that header is not. - Bots that skip the honeypot. The honeypot catches form-fillers that complete every input; it is one cheap filter, not the control. Turnstile and the rate limit are.
- The honeypot's name. It is chosen so autofill does not recognise it; a browser or extension that fills unknown fields anyway will have its submissions silently dropped.
- What intake returns. Field errors are relayed only in the contract's
shape and only for field names the browser itself sent; everything else from
intake becomes
unavailable. That narrows what an upstream fault can put on a page; it does not make intake's answers trusted. - The site's own logs and error reporting. The handler logs a closed set of codes and never a value, the token, the address or an error message. The site's framework, host or error SDK may still record the incoming request (its headers include the visitor's address), and that is outside this package.
- Stale pages. A page rendered against version 1 of a definition, posted after the route was re-pinned to version 2, is validated by intake against version 2; the visitor sees the resulting field errors.
For S9: what the scaffold should emit
S9 owns packages/project-scaffold. Measured against a throwaway project
generated from the unedited scaffold (the pull request that added this package
records the commands and numbers), the scaffold needs:
- The route, at
src/app/api/forms/[form_key]/route.tsin the generated project's web workspace, exactly the three lines under "Next: the route", with the project's pinned versions. ContactSectionandWaitlistSectionbeside the scaffold's other sections (src/components/sections/in the web workspace), each a"use client"component callinguseWeatherboardFormwith the project's own pinned definition object (since A-1668 the scaffold pins copies of the published rows, insrc/forms/definitions/, rather than importing a starter) and rendering every field with the design system:Selectfor achoice,Checkboxforconsent,Button(type="submit") to send,Textfor messages. Each renders the honeypot inside anaria-hiddenwrapper, theturnstileRefcontainer, and arole="status"element withformErrorId.PUBLIC_TURNSTILE_SITE_KEYinnext.config.ts'senvblock, besidePUBLIC_SENTRY_DSN, so a client component can read it; andFORMS_INGEST_URL/FORMS_INGEST_TOKENin the secrets manifest and.env.exampleas server-only.@weatherboard/formsin the web workspace'spackage.jsononce it is published.- The pages that place the sections (
/contact,/waitlist) inperf-budget.json'spages, so the budget and the a11y audit measure them.
Gaps the measurement found in the scaffold's design system, for S9 (or Gyde)
to decide rather than this package: there is no text-input or textarea
component, so text, email, url, postcode_uk and textarea fields
render native elements; and Checkbox and Select take no
aria-describedby or aria-invalid, so a consent or choice error cannot be
tied to its control through the design system as it stands.
Tests
pnpm --filter @weatherboard/forms test, under pnpm verify, no network:
handler.test.ts— every refusal the handler makes itself, every Wire contract v1 refusal code mapped, the size cap (claimed and streamed), the honeypot, the four forwarded headers, the Next adapter;canary.test.ts— a planted value, the ingest token and the visitor's address, through every path with the production log sink: none reaches a log line, a response or a Sentry-shaped event, and on the forwarding paths each does reach intake;form-state.test.ts— the shared fixtures through the client and the server validator; audience switching;react.test.ts— the hook in React DOM under jsdom, driving both starters; Turnstile not loaded before interaction, with the real loader;index.test.ts— every entry point's surface, and which files may import React or read the environment.
pnpm --filter @weatherboard/forms smoke packs the real tarball, installs it
into a bare project and consumes every entry point (ESM, CJS, TypeScript under
node16, a browser bundle that must not contain the handler). It needs the
network and is not in pnpm verify.
Publishing
As @weatherboard/annotation (docs/RUNBOOK-npm-publishing.md): pnpm packs,
npm publishes, because publishConfig.exports (the src/ → dist/ swap) is
applied by pnpm and not by the npm CLI. pnpm build enforces the per-entry
gzip budget in package.json#sizeBudget. Semver: 0.x while the browser
contract may still move; a new browser error code, a renamed hook field or a
changed default resolver is a breaking change.
