#!/usr/bin/env node /** * Cross-checks the intake form's two field tables. `npm run check:intake`. * * WHY THERE ARE TWO TABLES AT ALL, because the obvious reaction to this script * is to delete one of them and share an import. `docs/05-backend-spec.md`: * *"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 the Lambda * is a separately deployed zip that cannot import from `src/` anyway. * * So the duplication is architectural, and what makes it safe is this check * rather than a shared module: the two tables must agree on every field NAME, on * which fields are REQUIRED, on every length CAP, and on every closed OPTION * SET. If they disagree, the form offers something the handler rejects, or the * handler accepts something the form never shows — and the first is a lost * inquiry that looks like a bug in the browser. * * This is the one place in the repo where a duplicated fact is deliberate, and * `AGENTS.md`'s standing rule about duplicated facts is why it needs a mechanism * on top of a comment. * * Both files are read directly — Node strips the types out of the `.ts` — so * this script holds no third copy of the list. */ import { INTAKE_FIELDS, HONEYPOT_FIELD } from '../src/data/intake.ts'; import { FIELDS as SERVER_FIELDS, HONEYPOT, } from '../backend/intake/fields.mjs'; /** * BOTH TABLES ARE IMPORTED, NOT PARSED. The first version of this script read * `handler.mjs` as text, sliced out the `const FIELDS = [ … ]` literal, munged * quotes and commas into JSON, and guarded the result with a regex meant to * refuse anything executable. * * **That guard 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 munging underneath it would have broken on the first apostrophe or * URL in a label. The fix was not a better regex: the server's table moved into * `backend/intake/fields.mjs`, which has no module-scope side effects and can * simply be imported. The independence that matters is that the SERVER's table * lives with the server and the handler trusts nothing from `src/` — not that a * check script refuses to load it. */ const problems = []; const server = SERVER_FIELDS; const clientNames = INTAKE_FIELDS.map((f) => f.name); const serverNames = server.map((f) => f.name); for (const name of clientNames) { if (!serverNames.includes(name)) { problems.push( `"${name}" is on the form but the handler does not accept it — the ` + 'inquirer would fill it and it would be silently dropped.', ); } } for (const name of serverNames) { if (!clientNames.includes(name)) { problems.push( `"${name}" is validated by the handler but is not on the form.`, ); } } if (HONEYPOT !== HONEYPOT_FIELD) { problems.push( `honeypot name differs: form "${HONEYPOT_FIELD}", handler "${HONEYPOT}". ` + 'A bot fills the field the form renders; the handler checks the one it ' + 'knows about, so a mismatch disables the honeypot silently.', ); } if (serverNames.includes(HONEYPOT_FIELD)) { problems.push( `the honeypot "${HONEYPOT_FIELD}" is in the handler's FIELDS table; it must ` + 'be checked separately, or a bot filling it would just fail validation ' + 'instead of being sent to the success page.', ); } for (const clientField of INTAKE_FIELDS) { const serverField = server.find((f) => f.name === clientField.name); if (!serverField) continue; if (Boolean(clientField.required) !== Boolean(serverField.required)) { problems.push( `"${clientField.name}": form required=${Boolean(clientField.required)}, ` + `handler required=${Boolean(serverField.required)}. A field the form ` + 'marks optional and the handler requires is a rejection the inquirer ' + 'cannot see the reason for.', ); } /* LABELS TOO, since 2026-08-31. The handler now renders `f.label` into the confirmation email the inquirer keeps, so a label that drifts from the form's own wording means the receipt describes fields by names the form never showed. One more comparison; the duplication stays mechanical. */ if (clientField.label !== serverField.label) { problems.push( `"${clientField.name}": labels differ.\n` + ` form: ${JSON.stringify(clientField.label)}\n` + ` handler: ${JSON.stringify(serverField.label ?? null)}\n` + ' The handler renders its label into the confirmation email.', ); } if ((clientField.max ?? null) !== (serverField.max ?? null)) { problems.push( `"${clientField.name}": form max=${clientField.max ?? 'none'}, ` + `handler max=${serverField.max ?? 'none'}. The form's maxlength stops ` + 'typing; a lower cap in the handler rejects a submission that looked fine.', ); } const clientOptions = clientField.options ? [...clientField.options] : null; const serverOptions = serverField.options ? [...serverField.options] : null; if (JSON.stringify(clientOptions) !== JSON.stringify(serverOptions)) { problems.push( `"${clientField.name}": option sets differ.\n` + ` form: ${JSON.stringify(clientOptions)}\n` + ` handler: ${JSON.stringify(serverOptions)}`, ); } } console.log( `check:intake — ${clientNames.length} form fields, ${serverNames.length} ` + 'handler fields, compared on name, label, requiredness, cap and option set.', ); if (problems.length > 0) { console.error(`\nINTAKE TABLE MISMATCH — ${problems.length}:`); for (const p of problems) console.error(` - ${p}`); console.error( '\ndocs/05: the handler re-validates everything. The two tables are ' + 'independent on purpose; they still have to agree.', ); process.exit(1); } console.log('OK — the form and the handler agree.');