inmake DOCS
Docs / Server / Form submissions

Form submissions

The built-in form route and its guards.

Form submissions (FormGuard)#

WebSite.pushForm(form, locale, data, {path?, origin?}) forwards one public form submission to the core API (/public/form) through the site's SDK session, so the store token never reaches the browser. Content is validated against the form's contract: the kind:'form' schema field carries the structure ({key, kind:'variable', input} entries), and each variable's constraints/strings live in the placing document's per-locale value as a FormVariableValue descriptor ({type, label?, required?, pattern?, pattern_message?, max_length?, …}). The core resolves the two into the document's _f stamp (form slug → contract) on every save, and both this package and the core's re-validation read the contract from there — no schema read on a render server (a doc not stamped yet gets no local pre-validation; the core still validates it). Pass the submitting page's path (e.g. the Referer pathname) as options.path and pushForm validates locally first — against the very descriptors the visitor's page was rendered from, so rejections carry the doc's localized pattern_message and cost no core round-trip. A local rejection throws FormValidationError (map it to a 400). When the path resolves, the placing document's ref is forwarded as the push's source, so the core API re-validates against the same contract; a missing/unresolvable path forwards without a source (the core then applies only its baseline caps — object shape + 32 KB). Pass the browser's origin (the proxy request's Origin header) as options.origin — the core checks it against the form doc's origins allowlist (sdk-js forms.setOrigins; exact entries or one-label wildcards https://*.example.com; empty = unrestricted, mismatch = 401) since server-to-server calls carry no browser Origin of their own. A form doc with require_source: true refuses a push that arrives without a resolvable page (400 "Source required").

Uploads (input: image | file | files variables, .dev/plans/forms-v2.md): the built-in POST /form/{locale}/{form}/upload (multipart — a file part

  • the text field key) runs the same guard chain, then WebSite.pushFormUpload resolves the page's contract (the variable must be a file kind; its accept / max_size apply) and forwards the file to the core's /public/form/upload through the site's SDK session. The file is stored PRIVATE (no URL); the answer {upload, name, size, content_type} is what the page puts under the variable key in its later pushForm ({upload}, an array for files) — validateFormData(…, {uploads: 'ref'}) lets the claim through locally and the core resolves it (a ticket of this form, unconsumed, within 24 h). WebServerOptions.formUploadLimit caps the multipart body (default 10 MiB + the envelope). The body is parsed with the platform's own Response.formData() — no multipart dependency, nothing new in the browser bundle. Transport-level abuse is the proxy's job. Run a guard chain in front (see examples/instore/src/server.ts for the reference POST /form/:locale/:form route — the locale comes from the route the rendered page posts to, never from the payload):
import {
    FormGuardChain, HoneypotGuard, MinFillTimeGuard, MemoryRateLimitGuard,
} from 'drop-core-server';

const guards = new FormGuardChain([
    new HoneypotGuard(),
    new MinFillTimeGuard({key: process.env.FORM_KEY!}),
    new MemoryRateLimitGuard({limit: 5, windowMs: 60_000}),
]);
const verdict = await guards.verify({sid, form, data, ip: req.ip, headers});
if (!verdict.ok) return res.status(403).json({reason: verdict.reason});
for (const f of Object.keys(await guards.issue({sid, form}))) delete data[f];  // ← see below
await site.pushForm(form, locale, data, {path: refererPathname(req), origin: req.get('origin')});

An unguarded form endpoint on a public site is a spam sink — an empty chain is a local-dev default, not a deployment one.

FormGuard has two hooks: optional issue() returns hidden inputs/tokens to embed at render time; verify() judges one submission. FormGuardChain merges issue() maps and runs verify() in order — first failure wins, meta accumulates. Strip guard fields from data before pushForm — the core API rejects undeclared keys.

The built-in POST /form/:locale/:form route#

WebPreviewServer serves that route itself (reserved, like /data/**), so a store served through it needs no form plumbing at all: pass a formGuard (optional) and it reads the body (64 KiB cap), runs verify() with the transport facts from the request, strips the guard's issue() keys, and calls pushForm with the Referer path + the browser Origin. It answers 200 {id, status} · 400 {error, message} (validation — message is already localized by the document's pattern_message — or an unknown/inactive form) · 403 {error, reason} (guard, or the core's captcha refusal — see "reCAPTCHA" below). The visitor's IP — the guards' ctx.ip and the relayed remoteIp — comes from WebPreviewServerOptions.clientIp(headers); the default reads the first x-forwarded-for hop, which a browser controls behind a proxy that appends to that header (Fly, nginx), so a host behind one passes a resolver over its trusted header (the tenant does: TENANT_CLIENT_IP_HEADER).

GET on the same path issues the guard fields: {fields: {form_ts: "<ts>.<sig>", email_2: "", …}} (cache-control: no-store; {fields: {}} with no guard). The page's submit script fetches it at load and folds each field into the payload — an empty value is a honeypot (render it CSS-hidden, never type="hidden"), a non-empty value is a token to send back verbatim (type="hidden"). Issued per request, never baked into rendered HTML: pages are render-cached, so an embedded timestamp would stamp render time, not the visitor's page load — a guard with issue() fields but no fetch-at-load support in the page kit rejects every real submission. Consumers driving a bare WebSite (like examples/instore) implement the same path by hand; keep the shape, because that path is the contract rendered pages are written against:

// in the page template's JS — DROP_BASE/DROP_LOCALE are injected into every
// rendered page. A native <form> POST does NOT work: the route parses JSON.
const res = await fetch(`${window.DROP_BASE}/form/${window.DROP_LOCALE}/contact`, {
    method: 'POST',
    headers: {'content-type': 'application/json'},
    body: JSON.stringify({email, message, subscribe: true}),  // declared keys only
});

The built-in guards#

Three ship with the package. None needs a dependency, an account or a network call, and they are complementary — run all three. They stop undirected volume, which is what actually fills a submissions table; a captcha is the answer to a targeted attacker.

guard rejects notes
HoneypotGuard a filled hidden field (bot) Hide it with CSS, not type="hidden" — bots skip hidden inputs and fill visible ones. Give the wrapper aria-hidden, the input tabindex="-1".
MinFillTimeGuard submitted < minMs (3 s) after render → bot; > maxMs (1 h) → expired; bad HMAC → forged Needs a key: the token is <ts>.<HMAC-SHA256(ts, key)> (the access-seed scheme), so a bot that rewrites the timestamp breaks the signature.
MemoryRateLimitGuard more than limit (5) per IP per windowMs (60 s) → rate_limited Per-process (N replicas = N budgets) and read-modify-write, so it undercounts under concurrency. A first line, not a hard quota — back it with a shared Cache for that. The window slides: an over-limit IP stays blocked until it goes quiet. No ctx.ip ⇒ passes (a misconfigured proxy blocking every real submission is the worse failure).

Strip the guards' fields from data before pushForm — they are not in the form's contract and the core API rejects undeclared keys, failing the whole submission. WebPreviewServer's built-in route does this for you; examples/instore shows the hand-rolled version.

reCAPTCHA (verified by the core, relayed here)#

reCAPTCHA v3 is a platform feature, not a guard (.dev/plans/form-recaptcha.md): the core verifies, per form, so every path — this route, a hand-rolled server, a direct SDK caller — is covered by one check, and the store's secret never reaches a render server.

  • Store key — the store owner puts their own Google reCAPTCHA v3 (score) key in the console (Settings › Web › Spam protection): the site key lands in the public setup/web.captcha ({enabled, site_key, hide_badge}), the secret in the store's encrypted secret store.
  • Per-form switch — the form doc's captcha: {enabled, min_score?, low_score?} makes the core refuse a submission (and an upload) without a valid token for the action form_{slug} (captchaAction in core-lib).
  • Header relay — the browser sends the token as the x-drop-captcha request header (CAPTCHA_HEADER); the built-in routes relay it to pushForm / pushFormUpload as {captcha, remoteIp} (the SDK's captcha / remote_ip params). Nothing is checked here.
  • 403 pass-through — the core refuses with 403 and body.reason captcha_required / captcha_failed (the SDK error's .status / .body); the routes answer 403 {error: 'Forbidden', reason} — the guard shape pages already handle. Every other core rejection stays 400 {error: 'Rejected', message}.
  • Page runtime — with setup/web.captcha.enabled + a site key, WebSite composes captcha_runtime.ts into every page (see "Page composition"): it wraps fetch / XMLHttpRequest for same-origin POSTs to {DROP_BASE}/form/{locale}/{form}[/upload] only, lazy-loads www.recaptcha.net/recaptcha/api.js on the first form focus (or the first such POST), mints a fresh token per POST and sets the header; a refusal without a message gets the localized captcha.failed / captcha.consent copy added, and with hide_badge Google's badge is hidden. The runtime prints nothing on the page — a site that hides the badge states its use of reCAPTCHA itself, where and how it chooses (privacy policy, a line in the form template). Existing form templates need no change; window.DropCaptcha.token(form) is the escape hatch for code the wrappers can't see. The GET guard-fields answer adds captcha: {provider, site_key} for hand-written clients.

Other captchas stay sketches — they need a site key, a secret and a vendor call, and pinning that choice here would be worse than the few lines it saves:

  • Cloudflare Turnstile — POST ctx.data['cf-turnstile-response'] to https://challenges.cloudflare.com/turnstile/v0/siteverify with {secret, response, remoteip: ctx.ip}; !outcome.success → {ok: false, reason: 'bot'}.