/** * The intake form's fields. Spec: docs/05-backend-spec.md §Form fields. * * ⚠️ **THE LAMBDA HAS ITS OWN COPY OF THIS TABLE, AND THAT DUPLICATION IS * DELIBERATE — IT IS NOT THE SES-DKIM SHAPE.** `docs/05` is explicit: *"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 attacker what the rules are. So `backend/intake/handler.mjs` * carries an independent table and trusts nothing from here. * * What stops the two drifting is a check rather than a shared import: * **`npm run check:intake`** asserts that the two tables agree on every field * name, on which are required, and on every length cap — and fails the build * script if they do not. Independent validation, mechanically cross-checked. If * you add a field here, add it there, and the check will tell you if you didn't. * * WHAT THIS DATA IS, because it changes how the form is built (`docs/05`): in a * live legal dispute this collects the inquirer's identity, **the names of * opposing parties and their counsel**, and the nature of the dispute. That is * personal information about identifiable third parties who have not consented * and do not know the submission happened. Hence: no dollar amounts, no * uploads, an explicit unchecked consent box, and a matter summary whose hint * tells the writer not to put privileged detail in it. */ export type IntakeField = { name: string; label: string; /** `select` and `radio` carry `options`; everything else does not. */ type: 'text' | 'email' | 'tel' | 'select' | 'radio' | 'textarea' | 'checkbox'; required: boolean; /** Maximum characters. The Lambda REJECTS over this rather than truncating — * a silently truncated matter summary is a misread file. */ max?: number; options?: readonly string[]; /** Rendered under the field. */ hint?: string; /** `autocomplete` token, where one genuinely applies. Omitted rather than * guessed: a wrong token makes a browser fill the wrong value. */ autocomplete?: string; }; /** * ⚠️ **DO NOT ADD A DOLLAR-AMOUNT FIELD.** `docs/05`: *"Do not collect dollar * amounts, document uploads, or anything the inquirer might reasonably treat as * privileged. The intake call is for that."* The old site invented matter values; * this form is the one place a real one could arrive and then need storing. */ export const INTAKE_FIELDS: readonly IntakeField[] = [ { name: 'name', label: 'Your name', type: 'text', required: true, max: 120, autocomplete: 'name', }, { name: 'email', label: 'Email', type: 'email', required: true, max: 254, // RFC 5321 maximum path length; not a round number by choice. autocomplete: 'email', }, { name: 'phone', label: 'Phone', type: 'tel', required: false, max: 40, autocomplete: 'tel', hint: 'Optional.', }, { name: 'role', label: 'Your role', type: 'select', required: true, options: ['Counsel', 'In-house', 'Party', 'Institution', 'Other'], }, { name: 'organisation', label: 'Firm or organisation', type: 'text', required: false, max: 160, autocomplete: 'organization', }, { name: 'process', label: 'Process sought', type: 'select', required: true, /* The five from docs/05. "ENE" is expanded here because this is a form label read by a party as well as by counsel, and §11's glossary authority is about site copy rather than about abbreviating in a select. */ options: [ 'Mediation', 'Arbitration', 'Med-Arb', 'Early neutral evaluation', 'Not sure', ], }, { name: 'practiceArea', label: 'Subject matter', type: 'select', required: true, /* THE SIX AREAS PLUS OTHER. Deliberately the short display names rather than `PRACTICE_AREAS[].name`: those carry the full "Construction & Infrastructure" form for a card heading, and a select is not a card. The cross-check in `scripts/check-intake.mjs` compares these against the handler's list, and `PRACTICE_SLUGS` remains the site's own source for which areas exist. */ options: [ 'Construction', 'Technology', 'Energy', 'Insurance', 'Shareholder', 'Cross-border', 'Other', ], }, { name: 'otherParties', label: 'Other parties', type: 'text', required: false, max: 300, hint: 'Needed to run a conflicts check. Names only.', }, { name: 'opposingCounsel', label: 'Opposing counsel', type: 'text', required: false, max: 300, hint: 'Also for the conflicts check.', }, { name: 'summary', label: 'What the dispute is about', type: 'textarea', required: true, max: 2000, hint: 'A few sentences is enough. Please do not include privileged or confidential detail — that is what the intake call is for.', }, { name: 'timing', label: 'Timing', type: 'select', required: false, options: ['Urgent', 'Within 30 days', 'Within 90 days', 'Exploring'], }, { name: 'preferredContact', label: 'Preferred reply', type: 'radio', required: false, options: ['Email', 'Phone'], }, ]; /** * THE CONSENT TEXT, VERBATIM FROM `docs/05` §Consent text. It is a legal notice * the inquirer agrees to, so it is rendered from here and never retyped or * reworded to fit a layout. Note that it says the same three things * `NO_RETAINER_NOTICE` says — that constant is the site-wide statement and this * is the one the inquirer ticks; both ship on `/contact/`, which is deliberate: * `docs/01` requires the page to carry the notice, and `docs/05` requires the * checkbox to carry it too. * * ⚠️ **IT NAMES SML COMPANY LTD — Pouya's ruling, 2026-09-02 — AND THREE * CONSTRAINTS RIDE ON THAT.** **Name only, no terminal period**, and never * beside the licence-status row (`AGENTS.md` §4). **`docs/05` §Consent text is a * byte-identical second copy with no `check:` script over it**, so it moves with * this string. And **`/legal/privacy/` must keep naming the same party** — it * does, under §Why it is collected; a consent naming a company the linked policy * never mentions is an accountability gap, not a matter of voice. */ export const CONSENT_TEXT = 'I consent to SML Company Ltd storing and using the information in this form ' + 'to respond to my inquiry and to run a conflicts check. I understand that ' + 'submitting this form does not create a retainer, does not appoint a neutral, ' + 'and does not itself establish a mediator–party relationship.'; /** * The honeypot. `docs/05`: *"hidden from sighted and screen-reader users, must * be empty"*. * * ⚠️ **`display: none` PLUS `tabindex="-1"` PLUS `aria-hidden`, AND THE NAME * MATTERS.** A honeypot named `honeypot` is skipped by any bot worth stopping; * one named like a real field is filled. `company_website` is a plausible field * on a professional intake form and is not one this form has. It must never be * reachable by keyboard or announced by a screen reader — a honeypot that traps * a screen-reader user is an accessibility defect that also loses a real inquiry. */ export const HONEYPOT_FIELD = 'company_website'; /** * WHERE THE FORM POSTS — AND IT IS A SAME-ORIGIN PATH, NOT THE API GATEWAY * HOSTNAME. This is a design decision with four consequences, taken at step 8 * and recorded because the obvious implementation is the other one. * * The obvious version posts to the execute-api hostname `AGENTS.md` §7 records. * Posting to `/api/intake` instead, with a CloudFront behaviour routing `/api/*` * to that origin: * * 1. **`Content-Security-Policy: form-action 'self'`** — `docs/05` specifies * `form-action 'self' `; with a same-origin post the second * term is unnecessary, so the policy is strictly tighter. * 2. **No cross-origin POST at all**, so no CORS question for the form. (CORS * never governed it anyway — a form POST is a top-level navigation, not an * XHR, so it is exempt from preflight. `docs/05`'s CORS line protects the * endpoint against scripted calls from other origins, which is a different * control, and the handler's `Origin` check is what covers the form.) * 3. **The endpoint id stays out of the HTML.** ⚠️ It is NOT true that §7 is * the only place it lives, and this bullet said so: `.env.example` still * sets `PUBLIC_INTAKE_ENDPOINT` to the full execute-api hostname. That * variable is now read by nothing, so the line is dead as well as * duplicative. It is not edited here because this environment denies read * access to `.env.example`, and nothing may edit a file it cannot read — * it is in the batched list for Pouya instead. Found by * `adversarial-reviewer` round 2, against an unscoped sweep. * 4. **Submitting locally does nothing.** `astro dev` has no `/api/` route, so * a POST 404s. Under the alternative, clicking Submit on a laptop would * write a real DynamoDB record and send two real emails. * * ⚠️ **THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED: THE FORM DOES NOT * WORK UNTIL THAT CLOUDFRONT BEHAVIOUR EXISTS AND THE HANDLER IS DEPLOYED.** * Neither has been done — nothing on this project deploys before cutover (D11), * and both are checklist items in `docs/06`. Until then the page is complete and * the pipe behind it is not, which is why `/contact/` also publishes the email * address rather than treating the form as the only way in. */ export const INTAKE_ACTION = '/api/intake';