Build and deploy / build-and-deploy (push) Failing after 4s
Pouya's rulings of 2026-09-03 (the last two D20 findings) and 2026-09-04 (the spam observation and four mitigations), in one change set. D20 finding 10 — med-arb is billed BY PHASE, each phase at the rates already published, so /fees/'s "Every figure is on this page" is true as written rather than narrowed. FEES.medArb is the single source; docs/07 §Med-arb carries the rule INTERIM against R5, and R5 now carries it back, because a derived price moves silently when a rate moves. D20 finding 13 — conduct undertaking (g), attested 2026-09-03, published as his wording verbatim on /legal/privacy/ and /contact/. The clause that raised the finding promised to DISCLOSE a conflicts check's outcome, which the attestation does not cover; it is struck. D20 now partitions 17 fixed / 2 refuted / 1 owed. Spam, 2026-09-04 — recorded in docs/05 §Observed abuse with the date and signature. A second honeypot (a decoy checkbox, own class, `hidden`, a label that tells a human not to tick it) and scoring that LABELS and never rejects: nothing is dropped, nothing new is stored, and only the operator notification changes. Q65 opens the WAF cost call. The timing floor could not be built: there is no timing check and never has been. docs/05 carries it struck, and every mechanism that would give a real per-visitor clock breaks zero-JS, handler-and-form-only, or D1. Q66. configure.mjs gains section 5 — a custom origin request policy forwarding CloudFront-Viewer-Address on /api/*. Written, dry-run against the live distribution, NOT applied. It reads the handler's own header reads and refuses to run if the whitelist omits one. And reading the live account to do it found four AGENTS.md §7 rows saying the intake backend was undeployed, two days after it went live — corrected against get-function-configuration, get-routes, get-stage, get-policy and the deployed zip, which was downloaded and read. Review: adversarial-reviewer only (claims-auditor is D20's cutover pass and has run). Round 1 five lenses, 56 findings, 7 blocking, 4 refuted by an independent refuter; round 2 four lenses, 36 findings, 33 of them defects in round 1's own repairs. Stopped at two per D19. Gates, exit status read for each: check 0 · build 0 (23 pages) · check:claims 0 · check:intake 0 · og:proof 0 · lint 0 · spam-score.test 39/39 with 6/6 mutations killed · router.test 30/30 · minifier grep 1 (clean) · lighthouse 0, no category below 95 · configure.mjs dry run 0, nothing written. Nothing deployed and nothing applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
140 lines
5.3 KiB
JavaScript
140 lines
5.3 KiB
JavaScript
/**
|
|
* 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';
|