import type { BasePayload } from 'payload' /** A form-builder field, trimmed to what validation needs. */ type FormField = { blockType?: string label?: string name?: string required?: boolean | null } type FormDoc = { fields?: FormField[] id: number | string /** Per-form notification address, when the client added the field. */ notificationEmail?: string title?: string } /** * Validation outcome — codes, not user-facing strings. The frontend turns these * into its own copy (see SubmitFailure in submitForm). */ export type FormValidationResult = | { /** Offending field, when a single field is at fault. */ field?: string kind: 'required' | 'too_long' | 'unknown_fields' ok: false reason: 'invalid' } | { cleaned: Record; form: FormDoc; ok: true } | { field: string; ok: false; reason: 'consent' } | { ok: false; reason: 'not_found' } /** Field block types that don't carry a submittable value. */ const NON_DATA_BLOCKS = new Set(['message']) /** * Keys injected by the captcha widget itself, not by the form definition. * Cloudflare Turnstile adds a hidden after * a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the * plugin drives Turnstile end-to-end, these are legitimate artifacts — they * must not count as "unknown fields" and trip the anti-tampering check. */ const CAPTCHA_KEYS = new Set(['cf-turnstile-response', 'g-recaptcha-response']) /** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000 /** * Default name for a GDPR consent field. A checkbox with this name is treated * as a consent gate: it MUST be checked for the submission to go through, * enforced here server-side regardless of how the field was configured in the * panel (so an editor can't weaken it by forgetting `required` or, worse, * pre-ticking it with defaultValue: true — which GDPR forbids). Configurable * via FormsOption.consentFieldName. */ const DEFAULT_CONSENT_FIELD = 'consent' /** * Checks submitted data against the form's own definition, rather than trusting * whatever arrived. * * The server action is a public endpoint: a caller can skip the rendered form * and post arbitrary keys. Without this, unknown fields would be stored, * required fields could be missing, and an oversized value could sail through. * So we load the form, keep only keys that are real fields, reject when a * required one is blank, and cap length. * * Returns the loaded form on success so the caller doesn't fetch it twice, and * a code + offending field on failure so the frontend can point at it. */ export async function validateSubmission( payload: BasePayload, formId: string, data: Record, consentFieldName: string = DEFAULT_CONSENT_FIELD, ): Promise { let form: FormDoc try { form = (await payload.findByID({ id: formId, collection: 'forms', depth: 0, })) as FormDoc } catch { return { ok: false, reason: 'not_found' } } const fields = (form.fields ?? []).filter( (f): f is { name: string } & FormField => typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''), ) const known = new Map(fields.map((f) => [f.name, f])) const cleaned: Record = {} for (const field of fields) { const value = data[field.name] const isBlank = value == null || (typeof value === 'string' && value.trim() === '') || value === false // GDPR consent gate: a field matching the consent name must be truthy // (checked). Enforced independently of `required`, so it can't be weakened // in the panel. This is the one field where server-side enforcement is the // legal guarantee — the frontend can't bypass it, the editor can't misset it. if (field.name === consentFieldName) { if (value !== true) { return { field: field.name, ok: false, reason: 'consent' } } } else if (field.required && isBlank) { return { field: field.name, kind: 'required', ok: false, reason: 'invalid' } } if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) { return { field: field.name, kind: 'too_long', ok: false, reason: 'invalid' } } // Only carry through keys that belong to the form — unknown keys from a // hand-crafted request are dropped, not stored. if (value !== undefined) { cleaned[field.name] = value } } // Reject outright if the payload carried keys the form doesn't define — a // sign the request wasn't produced by the rendered form. Captcha keys are // exempt: the widget injects them into the rendered form, so they're expected, // not tampering. const unknownKeys = Object.keys(data).filter((k) => !known.has(k) && !CAPTCHA_KEYS.has(k)) if (unknownKeys.length > 0) { return { kind: 'unknown_fields', ok: false, reason: 'invalid' } } return { cleaned, form, ok: true } }