/** * The intake handler's OWN field table. Spec: docs/05-backend-spec.md §Form fields. * * ⚠️ THIS IS A SECOND, INDEPENDENT COPY OF THE FORM'S FIELD LIST, AND THE * DUPLICATION IS ARCHITECTURAL RATHER THAN AN OVERSIGHT. * * docs/05: "Client-side validation is a convenience. **The Lambda re-validates * everything.**" A server that validates against a list the client shipped it is * not validating — it is asking the caller what the rules are. And this file is * deployed inside the Lambda zip, which cannot import from `src/` at all. * * WHAT KEEPS THE TWO HONEST IS A CHECK, NOT A SHARED IMPORT. * `npm run check:intake` imports this module and `src/data/intake.ts` and * asserts they agree on every field name, on which are required, on every length * cap, and on every closed option set. A disagreement means either the form * offers something the handler rejects — a lost inquiry that looks like a * browser bug — or the handler accepts something no form ever shows. * * It lives in its own file rather than inside `handler.mjs` so the check can * import it. `handler.mjs` calls `requireEnv()` at module scope and throws * without a configured environment, so importing THAT would mean inventing * fixture credentials to run a check that has nothing to do with them. * (The first version of the check scraped this table out of the handler as text * and evaluated it. Its "refuse anything executable" guard then rejected the * table on the word `process` — which is a FIELD NAME. A guard that fires on the * data it exists to protect is worse than no guard, and the fix was to stop * scraping.) * * `select` and `radio` fields carry their option list, and a value outside it is * REJECTED rather than coerced — a select is a closed set, and a request that * sends something else is not a browser. * * ⚠️ **`label` IS HERE BECAUSE THE CONFIRMATION EMAIL PRINTED FIELD NAMES.** * `summaryLines` was `${f.name}: ${value}`, so the inquirer's receipt read * `practiceArea: Construction`, `otherParties: …`, `opposingCounsel: …`. That * email is the one artefact an inquirer keeps from this practice, and it is also * the artefact that quotes third-party names back at them, so its legibility is * not cosmetic. Found by `adversarial-reviewer`, 2026-08-31. * `npm run check:intake` compares labels as well as names, requiredness, caps * and option sets — so the receipt cannot drift from the form's own wording. */ export const FIELDS = [ { name: 'name', label: 'Your name', required: true, max: 120 }, { name: 'email', label: 'Email', required: true, max: 254 }, { name: 'phone', label: 'Phone', required: false, max: 40 }, { name: 'role', label: 'Your role', required: true, options: ['Counsel', 'In-house', 'Party', 'Institution', 'Other'], }, { name: 'organisation', label: 'Firm or organisation', required: false, max: 160, }, { name: 'process', label: 'Process sought', required: true, options: [ 'Mediation', 'Arbitration', 'Med-Arb', 'Early neutral evaluation', 'Not sure', ], }, { name: 'practiceArea', label: 'Subject matter', required: true, options: [ 'Construction', 'Technology', 'Energy', 'Insurance', 'Shareholder', 'Cross-border', 'Other', ], }, { name: 'otherParties', label: 'Other parties', required: false, max: 300 }, { name: 'opposingCounsel', label: 'Opposing counsel', required: false, max: 300, }, { name: 'summary', label: 'What the dispute is about', required: true, max: 2000, }, { name: 'timing', label: 'Timing', required: false, options: ['Urgent', 'Within 30 days', 'Within 90 days', 'Exploring'], }, { name: 'preferredContact', label: 'Preferred reply', required: false, options: ['Email', 'Phone'], }, ]; /** * The honeypot field name. NOT in `FIELDS`, and that is load-bearing: it is * checked before validation and a non-empty value gets the SUCCESS page, not a * rejection. Telling a bot it was detected is how the next version of the bot * stops filling the field. `check:intake` asserts it is absent from `FIELDS`. */ export const HONEYPOT = 'company_website'; /** * The SECOND honeypot — a decoy checkbox that must arrive ABSENT. Also not in * `FIELDS`, for the same reason, and `check:intake` asserts that too. * * ⚠️ **DIFFERENT TRAP, NOT A SECOND COPY.** `HONEYPOT` catches a bot that fills * every text input; this catches one that sets every control it enumerates. * * ⚠️ **IT IS PROBABLY INERT AGAINST THE 2026-09-04 PAIR, AND THE COMMENT HERE * SAID THE OPPOSITE FOR ONE ROUND.** They left `HONEYPOT` empty, so they skip * hidden fields — and a bot that skips a hidden text input skips a hidden * checkbox. `src/data/intake.ts` carries the full argument; this is defence in * depth against a different class, not a counter to the observed one. * * ⚠️ **UNCHECKED SENDS NOTHING, so absence is the pass — and so is an empty * value, because the handler tests for a non-empty one rather than for mere * presence.** See * `src/data/intake.ts` for the full reasoning; the two files state it separately * because they are separately deployed and `check:intake` is what keeps the * NAMES in step, not the comments. */ export const DECOY_CHECKBOX = 'updates_optin';