/** * The intake handler. Spec: docs/05-backend-spec.md. Resource names, region, * table and SES state: AGENTS.md §7 — this file reads them from the environment * and does not restate them. * * ⚠️ THIS IS LIVE. Deployed at cutover on 2026-09-02 by `docs/09` Part 5, and * `/api/intake` answers 303 to the Part 7.1 probe. It REPLACED a hand-built * `adr-intake-handler` that predates this repo. **This banner read "THIS IS NOT * DEPLOYED" until 2026-09-04**, which is the most dangerous thing a comment on * this file can say: an edit made in that belief ships to a form real inquirers * are using. Changes here reach production on the next `docs/09` Part 5 run. * * ⚠️ AND A BARE `POST /api/intake` RETURNS 403 BY DESIGN — the Origin check * below. `docs/09` §7.1 is the only valid route probe; a 403 without that header * is not evidence about the route. It has been misread as one twice. * * ── THE SHAPE, AND WHY IT IS POST-REDIRECT-GET ───────────────────────────── * * The site ships ZERO JavaScript (AGENTS.md §7, and it is not "minimal" — none). * So the form is a plain HTML POST, and this handler answers with **303 See * Other** and a `Location` on the site. That gives, with no script anywhere: * * - a working form with JavaScript disabled, which is the failure this whole * project exists to fix; * - no JSON response rendered as a raw page, which is what a plain POST to an * API Gateway JSON endpoint shows the user; * - no double submission on refresh, because the browser lands on a GET. * * docs/05's definition of done asks that the form "degrades to a mailto: * fallback with JavaScript disabled". It does not need to: there is nothing to * degrade FROM, because the form never used script. The email address is * published on /contact/ regardless. * * ── WHAT THIS DELIBERATELY DOES NOT IMPLEMENT ────────────────────────────── * * ⚠️ **RE-ASKED 2026-09-04 AND STILL NOT IMPLEMENTABLE HERE.** Pouya ruled * *"raise the timing floor"* after the first real spam. There is no floor to * raise — the check has never existed — and the reason below is unchanged by * the spam arriving: it is a property of a CDN-cached static page, not of how * hard anyone has tried. What CAN carry a per-visitor clock is named in * `docs/05` §Observed abuse and it is outside "handler + form only". §9 Q66. * * **THE 3-SECOND TIMESTAMP CHECK IS NOT IMPLEMENTED, AND THAT IS A DECISION.** * docs/05 asks to "reject submissions completed in under 3 seconds". It cannot * be done here and implementing it would produce a control that does nothing: * the check needs to know when the form was SERVED to that visitor, and * /contact/ is a static file cached at the CloudFront edge. A build-time * timestamp is the same value for every visitor and is hours or days old, so * `now - served` is always large — the check would pass for a bot exactly as it * passes for a human. A per-visitor token needs either a dynamic origin or * client-side script, and the site has neither by design. * * That is worse than omitting it: AGENTS.md Q22 and the Lighthouse row are both * records of what a control that exists on paper and not in fact costs here. So * it is omitted, said out loud, and the load is carried by the TWO honeypots, * the Origin check, the aggregate API Gateway route throttle and the validation * below — plus, since 2026-09-04, a score that LABELS and never rejects. * (Aggregate, not per-IP — see above; the earlier wording here said "rate * limit" and let the reader supply the stronger meaning.) * * ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ────────────────────────────── * * - An AGGREGATE API Gateway route throttle. NOT per source IP: API Gateway * throttling is per route and per stage across all callers, so docs/05's * "5 requests / 5 minutes per source IP" is struck — per-IP needs AWS WAF. * Never describe what ships as per-IP. docs/09 Part 6.3. * - CloudFront behaviour: /api/* → the HTTP API origin §7 records. * - CloudWatch alarms on Lambda `Errors` and on API Gateway 5xx for this * route. NOT a dead-letter queue: `DeadLetterConfig` is used only for * ASYNCHRONOUS invocations, API Gateway invokes synchronously, so a DLQ here * would sit at depth 0 for ever and an alarm on it would be a permanently * green light. docs/05 §Notification carries the replacement. * - The `ses-alerts` SNS email subscription is CONFIRMED (§7) — R9 closed * 2026-09-01, so the bounce and complaint alarms reach someone. */ import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'; import { randomUUID } from 'node:crypto'; /* The field table and BOTH honeypot names live in their own module so that `npm run check:intake` can import them without this file's module-scope `requireEnv()` calls running. See fields.mjs for why there are two tables. */ import { DECOY_CHECKBOX, FIELDS, HONEYPOT } from './fields.mjs'; /* Scoring lives in its own module so it can be unit-tested — this file throws at import without a configured environment, so it cannot be. `node backend/intake/spam-score.test.mjs`. ⚠️ IT IS A THIRD FILE IN THE ZIP: `docs/09` Part 5.1 packages it explicitly, and a cold start would fail with ERR_MODULE_NOT_FOUND if it were left out. */ import { isPossibleSpam, scoreSubmission, SPAM_THRESHOLD, } from './spam-score.mjs'; /* Region comes from the Lambda runtime, which sets AWS_REGION to the function's own region — the one §7 records. Not hardcoded: a second copy of a fact §7 owns is the copy that goes stale. */ const ddb = new DynamoDBClient({}); const ses = new SESv2Client({}); const TABLE = requireEnv('INTAKE_TABLE'); const SITE_ORIGIN = requireEnv('SITE_ORIGIN'); const NOTIFY_TO = requireEnv('NOTIFY_TO'); const MAIL_FROM = requireEnv('MAIL_FROM'); /** 24 months, docs/05 §Retention. It must match /legal/privacy/ exactly. * ⚠️ Writing this attribute is NOT the mechanism — TTL must be enabled on the * table, and AGENTS.md §7 records whether it is. */ const RETENTION_MONTHS = 24; /** The public commitment, §4 and Q27. It must read identically here, on * /contact/, and in any bio. Injected rather than typed so one edit moves all * three; the deploy step sets it from `CONTACT.responseTime`. */ const RESPONSE_TIME = requireEnv('RESPONSE_TIME'); /** * ⚠️ INJECTED FOR EXACTLY THE REASON ABOVE, AND IT WAS HAND-TYPED UNTIL * 2026-08-31. The confirmation email spelled the no-retainer notice out in * prose, which made it a **fourth** hand-copy of `NO_RETAINER_NOTICE` — and the * copy **dropped the fourth clause the constant carries**, *"and does not itself * create a conflict check"*, which `docs/01` §`/contact/` requires. It also used * a hyphen where the constant uses an en dash. * * The reasoning three lines above applied to it identically and was not applied. * `npm run check:intake` compares field tables only, so a future softening of * the constant would never have reached this email and nothing would have * failed — the silent-drift shape §4 flags for the whole commitment class. * Found by `adversarial-reviewer`. The deploy step sets it from * `NO_RETAINER_NOTICE` in `src/data/site.ts`. * * ⚠️ AND THAT SENTENCE USED TO END "`docs/06` names it", WHICH IT DID NOT. * This variable became a `requireEnv` and reached no document — so the * deployment list said five variables while this file required six, and the * function would have thrown at cold start on every invocation. **The comment * asserting the documentation existed is what made it invisible.** `docs/06` and * `docs/05` now name all six. Found by `adversarial-reviewer` round 2. */ const NO_RETAINER_NOTICE = requireEnv('NO_RETAINER_NOTICE'); function requireEnv(name) { const value = process.env[name]; if (!value) { // Fail at cold start, not per request: a function missing configuration // should not accept a submission it cannot store. throw new Error(`intake handler: ${name} is not set`); } return value; } /** * Strip HTML before storage and before anything enters an email body (docs/05). * * NOT AN HTML SANITISER, AND IT DOES NOT NEED TO BE — every field is stored and * rendered as PLAIN TEXT, never as markup, so the job is to make a value * incapable of becoming markup later, not to allow safe markup now. * * ⚠️ AND FOR THAT REASON IT STRIPS ANGLE BRACKETS RATHER THAN ENTITY-ENCODING * THEM. The first version of this function escaped `&` to `&`, which is * correct only when the sink is HTML: both sinks here are plain text, so the * reader of the confirmation email would have received the five literal * characters `&` wherever they had typed an ampersand. Encoding for the * wrong sink is a defect wearing the costume of a protection. */ function toPlainText(value) { return ( value // Control characters, including the CR/LF that would let a value forge a // header line in an email, and the C1 range. // // `no-control-regex` is disabled ON PURPOSE and with the reason: that rule // exists to catch a control character that reached a pattern by ACCIDENT, // usually a mis-escaped literal. Here the control range IS the thing being // matched, and it is the one part of this function that stops a submitted // value from forging an email header. Rewriting it as a charCodeAt filter // to satisfy the linter would make the intent less legible, not more. // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') // Angle brackets removed rather than entity-encoded. The destination is // plain text — a DynamoDB string attribute and a text/plain email body — // so `&` would REACH THE READER as the five characters "&", which // is a defect rather than a protection. Encoding is right when the sink is // HTML; here the requirement is only that the value can never become // markup if it is later put into one, and no `<` satisfies that // permanently. Nothing else in the value is altered. .replace(/[<>]/g, '') .replace(/[ \t]{2,}/g, ' ') .trim() ); } /** * Email validation, server side. Deliberately structural rather than clever: * one @, something either side, a dot in the domain, no whitespace, no angle * brackets, within the RFC 5321 length. A regex that tries to implement RFC 5322 * rejects real addresses, and the confirmation email in D18 is the real check — * if it does not arrive, the address was wrong whatever a regex said. */ function looksLikeEmail(value) { return ( value.length <= 254 && /^[^\s@<>]+@[^\s@<>.]+(\.[^\s@<>.]+)+$/.test(value) && !value.includes('..') ); } function parseBody(event) { const raw = event.isBase64Encoded ? Buffer.from(event.body ?? '', 'base64').toString('utf8') : (event.body ?? ''); const type = headerOf(event, 'content-type') ?? ''; if (type.includes('application/x-www-form-urlencoded')) { return Object.fromEntries(new URLSearchParams(raw)); } // JSON is accepted so the endpoint stays testable with curl, and because a // future island could post JSON without changing this handler. if (type.includes('application/json')) { const parsed = JSON.parse(raw); if ( parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) ) { throw new Error('body is not an object'); } return parsed; } throw new Error(`unsupported content-type: ${type}`); } /** * ⚠️ THE UNFORGEABLE VALUE, AND NOT THE USEFUL ONE. `requestContext.http * .sourceIp` is the TCP peer, which behind the CloudFront behaviour that routes * /api/* is a CloudFront EDGE — so this records AWS rather than the inquirer. * * IT READ `x-forwarded-for` FOR ONE REVISION AND THAT WAS WORSE. CloudFront * APPENDS the viewer address to a client-supplied XFF rather than replacing it, * so the leftmost entry is whatever the client sent: a submission with * `X-Forwarded-For: 8.8.8.8` stored `8.8.8.8`. That turns a field held for abuse * investigation into one that can be made to name an uninvolved third party, and * /legal/privacy/ promises the record holds "your IP address". A forgeable value * presented as an identification is worse than an honest useless one. * * The right value is CloudFront's own `CloudFront-Viewer-Address`, which * CloudFront generates and overwrites. Reaching it needs a CUSTOM origin request * policy on the /api/* behaviour — the managed AllViewerAndCloudFrontHeaders * forwards Host, which 403s every request at API Gateway. * * ⚠️ THAT POLICY IS NOW WRITTEN — `infra/cloudfront/configure.mjs` section 5, * Pouya's ruling of 2026-09-04 — SO THE HEADER MAY ARRIVE. THIS FUNCTION STILL * DOES NOT READ IT, AND THAT IS THE RULING, NOT AN OMISSION: *measured, not yet * acted on*. What the record holds is published field by field on * /legal/privacy/, so storing a different address is a DISCLOSURE change * governed by `docs/09` §7.2's decision table — an infrastructure change * forwards a header; only a privacy-policy change may store one. Do not "fix" * this from any header without that measurement and that edit. */ function viewerIp(event) { return event.requestContext?.http?.sourceIp ?? 'unknown'; } function headerOf(event, name) { const headers = event.headers ?? {}; // API Gateway HTTP API lowercases header keys; a direct invoke or a test // harness may not, so this does not assume it. const hit = Object.keys(headers).find((k) => k.toLowerCase() === name); return hit ? headers[hit] : undefined; } const redirect = (path) => ({ statusCode: 303, headers: { Location: `${SITE_ORIGIN}${path}`, // A redirect that a CDN or a browser caches would send the next visitor // straight to the confirmation page without submitting anything. 'Cache-Control': 'no-store', }, body: '', }); const SUCCESS = '/contact/received/'; const FAILURE = '/contact/could-not-send/'; export async function handler(event) { /** * ORIGIN CHECK, AND IT IS THE CONTROL CORS IS USUALLY MISTAKEN FOR. A form * POST is a top-level navigation: it is exempt from CORS preflight, so an * `Access-Control-Allow-Origin` setting on the endpoint does not stop another * site from posting a form here. Checking the header does. * * Firefox omits `Origin` on some same-origin form navigations, so `Referer` is * accepted as a fallback — both must MATCH the site origin when present, and * a request with neither is refused. */ const origin = headerOf(event, 'origin'); const referer = headerOf(event, 'referer'); const originOk = origin ? origin === SITE_ORIGIN : referer ? referer.startsWith(`${SITE_ORIGIN}/`) : false; if (!originOk) { return { statusCode: 403, headers: { 'Cache-Control': 'no-store' }, body: '', }; } let body; try { body = parseBody(event); } catch { return redirect(FAILURE); } /** * THE HONEYPOT GETS THE SUCCESS PAGE, NOT AN ERROR. Telling a bot it was * detected is how the next version of the bot stops filling the field. A * human cannot reach this field — it is `display: none`, `tabindex="-1"` and * `aria-hidden` — so a non-empty value is not a mistake anyone made. */ /* ⚠️ COERCED, NOT TYPE-CHECKED. `parseBody` accepts JSON, so a value can arrive as `true` or `1` rather than a string — and `typeof === 'string'` let exactly that through both traps for one round. `String(v).trim()` catches every non-empty shape and still treats absence as a pass. */ if (body[HONEYPOT] !== undefined && String(body[HONEYPOT]).trim() !== '') { /* LOGGED, BECAUSE THIS IS ONE OF ONLY TWO PATHS THAT DISCARD A SUBMISSION AND ANSWER WITH THE SUCCESS PAGE. Unlogged, a honeypot that starts firing on real visitors — a stylesheet that 404s, an autofiller, a template edit that unhides the wrapper — is indistinguishable from quiet weeks, and the only signal is inquiries that were never mentioned again. The FIELD NAME only: the value is whatever a bot chose and nothing about the submission is kept, which is what makes this safe to log at all. */ console.warn('intake: discarded by honeypot', { field: HONEYPOT }); return redirect(SUCCESS); } /** * THE SECOND HONEYPOT, AND IT TRAPS A DIFFERENT BEHAVIOUR. A checkbox no * person can see; an unchecked box sends nothing at all, so a VALUE arrives * only because something ticked it. The value itself is not compared — * `=1`, `=yes` and `=on` are all a tick — only that there is one. * * Same silent SUCCESS as above, and for the same reason. * * ⚠️ ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE. Both directions matter and * they fail differently: * * - Requiring the field to ARRIVE would turn every dropped-field path — an * extension, a proxy, a template edit — into a lost inquiry reported as * sent. * - Trapping on mere PRESENCE (`!== undefined`) would catch a form * serialiser that emits `updates_optin=` for a hidden checkbox without * reading its checked state. That is rare and it is not impossible, and * the cost of being wrong is a real legal inquiry discarded in silence. * * So the test is the same shape as the honeypot above — a non-empty value — * while the BEHAVIOUR it catches is the opposite one. That is the distinction * that matters: filling text fields versus ticking boxes, not `undefined` * versus `''`. */ if ( body[DECOY_CHECKBOX] !== undefined && String(body[DECOY_CHECKBOX]).trim() !== '' ) { console.warn('intake: discarded by honeypot', { field: DECOY_CHECKBOX }); return redirect(SUCCESS); } const clean = {}; const errors = []; for (const field of FIELDS) { const rawValue = body[field.name]; const value = typeof rawValue === 'string' ? rawValue.trim() : ''; if (value === '') { if (field.required) errors.push(`${field.name} is required`); continue; } // REJECT over the cap rather than truncating (docs/05). A silently // truncated matter summary is a file read wrongly. if (field.max && value.length > field.max) { errors.push(`${field.name} exceeds ${field.max} characters`); continue; } if (field.options && !field.options.includes(value)) { errors.push(`${field.name} is not one of the offered values`); continue; } if (field.name === 'email' && !looksLikeEmail(value)) { errors.push('email is not a well-formed address'); continue; } clean[field.name] = toPlainText(value); } // Explicit, unchecked by default, and required (docs/05). An unchecked box // sends no value at all, so absence is the failure case. if (body.consent !== 'on' && body.consent !== 'true') { errors.push('consent was not given'); } if (errors.length > 0) { // Logged for the operator, never returned to the caller: an error list is a // description of the validation rules, which is a gift to whoever is // probing them. console.warn('intake rejected', { errors }); return redirect(FAILURE); } const now = new Date(); const id = randomUUID(); /* NO `|| 0` FALLBACK (removed 2026-08-31): DynamoDB will not expire an item whose TTL is more than five years past, so `ttl: 0` means RETAINED FOREVER while /legal/privacy/ promises deletion. Let a bad value fail the write. */ const ttl = Math.floor( Date.UTC( now.getUTCFullYear(), now.getUTCMonth() + RETENTION_MONTHS, now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds(), ) / 1000, ); /** * DYNAMODB FIRST, THEN MAIL — docs/05: "SES failure must never lose the * submission." The order is the whole guarantee. If SES fails after this * write, the record exists and a resend has something to resend; if the write * fails, nothing was accepted and the inquirer is told so. (This said "the DLQ * replay" — there is no DLQ and there cannot usefully be one on a * synchronously invoked function; see the note at the top of this file.) */ try { await ddb.send( new PutItemCommand({ TableName: TABLE, Item: { /* ⚠️ `submissionId` IS THE TABLE'S PARTITION KEY AND THERE IS NO SORT KEY. A DynamoDB key schema cannot be altered after creation, so this attribute name is fixed by the table `AGENTS.md` §7 names, not chosen here — and an item missing it fails the whole write with `ValidationException`, which this function converts into the failure page. Verify against `describe-table` before changing either name; `submittedAt` is an ordinary attribute and is free. */ submissionId: { S: id }, submittedAt: { S: now.toISOString() }, ttl: { N: String(ttl) }, // Abuse investigation only (docs/05). Named so a later reader does not // repurpose them: they are not analytics and not part of the reply. /* Behind CloudFront this is the EDGE address, not the inquirer's. See `viewerIp()` — and read it before changing this. */ sourceIp: { S: viewerIp(event) }, userAgent: { S: (headerOf(event, 'user-agent') ?? 'unknown').slice(0, 400), }, consentAt: { S: now.toISOString() }, ...Object.fromEntries( Object.entries(clean).map(([k, v]) => [k, { S: v }]), ), }, }), ); } catch (error) { console.error('intake: DynamoDB write failed', error); return redirect(FAILURE); } // `f.label`, not `f.name` — see the note on `label` in fields.mjs. The // notification to the operator gets the same rendering: one shape, so the two // messages cannot describe the same submission differently. const summaryLines = FIELDS.filter((f) => clean[f.name] !== undefined) .map((f) => `${f.label}: ${clean[f.name]}`) .join('\n'); /** * SCORING, AND IT LABELS RATHER THAN REJECTS — Pouya, 2026-09-04. * * ⚠️ THIS RUNS AFTER THE RECORD IS STORED, WHICH IS NOT AN ACCIDENT OF * ORDERING. Nothing below can decline a submission: by the time it runs, the * write has already succeeded and the only remaining question is what the * OPERATOR's subject line says. There is deliberately no branch here that can * reach `redirect(FAILURE)`. * * ⚠️ AND IT TOUCHES THE NOTIFICATION ONLY. The confirmation below is * unchanged. A real inquirer wrongly scored must never be told that a machine * thought they were a bot. */ /* ⚠️ WRAPPED, AND THE GUARD IS THE RULING RATHER THAN CAUTION. An exception here would escape `handler`, API Gateway would answer 500, and the inquirer would see a failure for a submission ALREADY WRITTEN to the table — a path that costs an inquiry, decided by a labelling function. Pouya's constraint is that nothing but a honeypot may cost one, so the scorer is allowed to fail and the submission is not. Unlabelled is the safe default. */ let spam = { score: 0, signals: [] }; try { spam = scoreSubmission(clean); } catch (error) { console.error('intake: spam scoring failed; sending unlabelled', { id, error, }); } const flagged = isPossibleSpam(spam); const notificationBody = [ `Received ${now.toISOString()}`, `submissionId ${id}`, ...(flagged ? [ '', `Possible spam. Score ${spam.score} of threshold ${SPAM_THRESHOLD}. ` + `Signals: ${spam.signals.join('; ')}.`, ] : []), '', summaryLines, '', ].join('\n'); /** * TWO EMAILS — D18, and the second one is why the form beats a mailto: link. * `Promise.allSettled`, not `Promise.all`: the record is already stored, so a * failure on either message must be logged rather than lost, and one failing * must not prevent the other from being attempted. */ const results = await Promise.allSettled([ ses.send( new SendEmailCommand({ FromEmailAddress: MAIL_FROM, Destination: { ToAddresses: [NOTIFY_TO] }, // Replyable to the inquirer (docs/05), which is what makes the // notification usable without copying an address out of it. ReplyToAddresses: [clean.email], Content: { Simple: { /* The prefix is what Pouya filters on in Gmail, so it is the FIRST thing in the subject and it is a fixed string. Do not make it conditional on anything else, and do not vary its wording. */ Subject: { Data: `${flagged ? '[Possible spam] ' : ''}` + `Intake — ${clean.name} (${clean.practiceArea})`, }, Body: { // The body carries the bare submissionId, because it is the // partition key: that line gets pasted into the console to find // the record, so it must be the key and not a rendering of it. Text: { Data: notificationBody }, }, }, }, }), ), ses.send( new SendEmailCommand({ FromEmailAddress: MAIL_FROM, Destination: { ToAddresses: [clean.email] }, Content: { Simple: { Subject: { Data: 'Your inquiry has been received' }, Body: { Text: { Data: [ `Thank you — your inquiry has been received.`, ``, RESPONSE_TIME, ``, NO_RETAINER_NOTICE, ``, `What you sent:`, ``, summaryLines, ``, `How this information is handled, and how to ask for it to be`, `deleted: ${SITE_ORIGIN}/legal/privacy/`, ``, ].join('\n'), }, }, }, }, }), ), ]); results.forEach((result, i) => { if (result.status === 'rejected') { console.error( `intake: SES send ${i === 0 ? 'notification' : 'confirmation'} failed`, { id, reason: result.reason }, ); } }); // The submission is stored. Mail failures are an operator problem, not the // inquirer's, and telling them it failed would invite a second submission of // a record that already exists. return redirect(SUCCESS); }