Files
adr-sml/backend/intake/handler.mjs
T
Pouya LajevardiandClaude Opus 5 9f2d2eeb04
Build and deploy / build-and-deploy (push) Failing after 4s
fix: resolve adversarial review round 2 — 9 findings, 8 of them in round 1's fixes
D19 caps the loop at two rounds, and this is what the second round is for.

BLOCKING. Round 1 made NO_RETAINER_NOTICE a requireEnv and added it to no
document, while the fix's own comment claimed docs/06 named it. The deployment
list said five variables for a handler that needs six, so an operator following
the cutover checklist would have deployed a function that throws at cold start
on every invocation — 5xx from API Gateway, every inquiry lost from the moment
/api/* was wired, loud in CloudWatch and silent to Pouya. docs/05 and docs/06
now name all six, and the comment that asserted the documentation existed is
corrected rather than deleted.

The intake route check added in round 1 could not fail: curl -w already prints
000 on a failed transfer, so `|| echo 000` double-appended and the failure arm
was unreachable, and the pass arm accepted anything that was not literally 404 —
including the 403 CloudFront returns when the /api/* behaviour is missing, which
is the one distinction the check exists to draw. It now sends the correct Origin
and asserts a positive: 303 to /contact/could-not-send/, which the handler
returns before any DynamoDB write or email. Probed on refused/501/403/303; the
old version passed the first three. Fixed in both deploy paths.

Removing priceRange left three statements saying it was present or pending, one
of them the stated reason /fees/ emits no Offer node. Deleting
overtimeStartsAfterSessionHours left AGENTS.md §9 naming it and left Q59
recorded as open. The Google-as-processor fix was applied to the privacy
policy's "Where it is stored" and not to "Who can see it", which still read
"Nobody else has access".

And the variable removal was justified with a path-scoped git grep — which also
cannot see untracked files. The unscoped sweep found docs/06's variable table,
the OIDC example, and .env.example still carrying them; .env.example also
restates the execute-api hostname, falsifying a live claim in intake.ts that has
been corrected. That file is not edited here: this environment denies read
access to it, and nothing may edit a file it cannot read. It is in the batched
list.

Also: og:image:alt was the page title rather than the card's headline on 20
pages; og-card.ts documented the wrong path and invocation for the contact
sheet; deploy-local.sh still said Q22's deploy credential "does NOT yet exist";
and the round-1 fix comments were trimmed per D19, though the ratio held at 0.44.

Round 2 also confirmed the round-1 fixes by measurement: all 56 .btn instances
across 22 pages, the consent checkbox's computed accessible name, the radio
labels hit-tested at 44px, and og:proof exercised against synthetic article
pages in a sandbox.

Verified: check/build/check:claims/og:proof/check:intake/lint/bio:pdf all exit 0
on a clean build; 22 pages; Lighthouse 99-100 / 100 / 100 / 100, CLS 0.000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-08-31 11:21:07 -04:00

431 lines
18 KiB
JavaScript

/**
* 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 API Gateway rate limit and the validation below.
*
* ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ──────────────────────────────
*
* - API Gateway throttling, 5 requests / 5 minutes per source IP (docs/05).
* Not expressible in handler code.
* - CloudFront behaviour: /api/* → the HTTP API origin §7 records.
* - A dead-letter queue on this function and a CloudWatch alarm on DLQ depth
* >= 1 (docs/05). This handler writes to DynamoDB BEFORE sending mail so a
* DLQ replay cannot lose a submission.
* - The `ses-alerts` SNS email subscription is PENDING CONFIRMATION (§7, R9).
* Until it is confirmed the bounce and complaint alarms fire into nothing.
*/
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 — enforced by DynamoDB TTL, "not a policy
* someone remembers". It must match /legal/privacy/ exactly. */
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 `&amp;`, 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 `&amp;` 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 `&amp;` would REACH THE READER as the five characters "&amp;", 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}`);
}
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();
const ttl =
Math.floor(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth() + RETENTION_MONTHS,
now.getUTCDate(),
now.getUTCHours(),
now.getUTCMinutes(),
now.getUTCSeconds(),
) / 1000,
) || 0;
/**
* 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 the DLQ replay has something to replay; if the
* write fails, nothing was accepted and the inquirer is told so.
*/
try {
await ddb.send(
new PutItemCommand({
TableName: TABLE,
Item: {
pk: { S: `INTAKE#${id}` },
sk: { 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.
sourceIp: { S: event.requestContext?.http?.sourceIp ?? 'unknown' },
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: {
Data: `Received ${now.toISOString()}\nRecord INTAKE#${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);
}