/** * 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 NOT DEPLOYED. Written at build step 8; nothing on this project * deploys before cutover (D11). AGENTS.md §7 records that a hand-built * `adr-intake-handler` already exists in the console, created before this repo, * and this file REPLACES it rather than describing it. docs/06's cutover * checklist carries the deployment steps and the CloudFront `/api/*` behaviour * the form depends on. Until both are done the form on /contact/ posts into * nothing, which is why that page also publishes the email address. * * ── 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 ────────────────────────────── * * **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 honeypot, the * Origin check, the aggregate API Gateway route throttle and the validation * below. (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 the honeypot name 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 { FIELDS, HONEYPOT } from './fields.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 — but reaching it needs a CUSTOM origin * request policy on the /api/* behaviour (the managed * AllViewerAndCloudFrontHeaders forwards Host, which 403s every request at API * Gateway, which is why AllViewerExceptHostHeader was chosen). That is an * infrastructure change, and `docs/09` Part 7.2 measures what this field * actually contains at cutover rather than reasoning about the proxy chain — * with a decision table for each outcome. Do not "fix" this from the header * again without that measurement. */ 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. */ if (typeof body[HONEYPOT] === 'string' && body[HONEYPOT].trim() !== '') { 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'); /** * 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: { Subject: { Data: `Intake — ${clean.name} (${clean.practiceArea})` }, Body: { Text: { // The bare id, because it is the partition key: this line is // what gets pasted into the console to find the record, so it // must be the key and not a rendering of it. Data: `Received ${now.toISOString()}\nsubmissionId ${id}\n\n${summaryLines}\n`, }, }, }, }, }), ), 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); }