Files
adr-sml/backend/intake/handler.mjs
T
Pouya LajevardiandClaude Opus 5 64bce105f8 feat: eyebrow 12px → 13px from one definition; confirm and gate the intake TTL
Two of Pouya's rulings of 2026-08-31, committed as one tree at his instruction
("as-is") because AGENTS.md Change Log entry (ai) covers both and splitting it
would mean rewriting the record rather than moving it. This is a deliberate
departure from one-logical-change-per-commit, recorded here rather than left to
be inferred.

THE EYEBROW. `--text-eyebrow: 0.8125rem` added to tokens.css; `.eyebrow` in
global.css retargeted to it. One edit site, which is what the design system
claimed. 13px is not a rung on the modular ladder — it sits between xs and sm
deliberately, because uppercase mono at 0.18em tracking reads smaller than it
measures. `--text-2xs`'s comment stopped calling itself the eyebrow floor.

The rendered sweep (22 pages × 2 widths, 838 mono elements measured over CDP,
not grepped) found exactly one escape and one deliberate override:

  - h2.footer-heading, 176 instances — an ESCAPED EYEBROW. Five declarations
    byte-identical to `.eyebrow`, differing only in colour. Consolidated to
    `class="eyebrow footer-heading"`; the scoped rule is now colour + margin.
    The colour is load-bearing, not decorative: `.eyebrow`'s own `--text-meta`
    on ink is 3.07:1 and fails.
  - span.eyebrow.brand-tagline, 21 instances — HELD at `--text-2xs`. Measured:
    at 13px the header grows 81 → 83.4px while `--header-h` is pinned at 81 and
    drives `scroll-padding-top`; and with a seventh nav item the CTA lands past
    `.header-inner`'s content edge by 42px at 1216, 18px at 1240, 26px at 1280
    and 1440. Document overflow is 0 in all of those, so no page-level check
    can see it. Insights is that seventh item.

Everything else mono-uppercase is a genuinely different component and was left:
the 0.06em `--tracking-wide` family, the 14px mixed-case designation strip, the
/bio/ print sheet, /contact/'s form labels.

Measured after: contrast unchanged on all 817 instances (11.09 / 8.11 / 5.47 /
5.01:1, all pass at 13px, which is still normal text and needs 4.5:1). Zero
document overflow and identical header geometry at 15 widths. Two eyebrows gain
a line below 414px — /'s hero, already wrapping at 320px before this, and
/insights/'s empty state at 320px only. Accepted, not re-tuned.

docs/02's type spec moved 11–12px → 13px and now enumerates the three 11px
carve-outs instead of implying there are none. The /type-scale/ proof sheet
(d) asked for no longer exists — deleted at build step 2 — so the spec prose is
the proof sheet now.

THE TTL. backend/intake/handler.mjs CONFIRMED to match `AttributeName=ttl`: it
writes `ttl` as a Number, in epoch seconds, at RETENTION_MONTHS = 24. Nothing
needed changing for the enable command.

Removed `|| 0` from the TTL computation. DynamoDB does not expire an item whose
TTL is more than five years past, so `ttl: 0` meant RETAINED FOREVER while
/legal/privacy/ promises deletion — a fallback whose failure mode was the exact
inverse of the claim it was protecting. Unreachable in practice, which is why
it would never have been noticed. A bad value now fails the write.

§7 records TTL as DISABLED at first verification, so the privacy policy's
automatic-deletion promise was unbacked from the moment it was written. §7 is
the only place that status lives; docs/05 and docs/06 carry the constraint and
cite §7, because round 2 of review caught this change set reproducing the SES
DKIM defect — five copies of a status that is about to be re-stamped.

Added, and these are the gate: `TODO(pouya)` on /legal/privacy/'s retention
section, §9 Q60, §12 R19. The page does not publish a period, it asserts a
MECHANISM — deleted by the database rather than by someone remembering — and
nothing in the toolchain can see that. check:claims is frozen with no pattern
for it and deploy does not read docs/06. The copy was NOT softened: it is about
to be true, and weakening a privacy commitment to make it defensible is the
move the rules forbid. What was missing was the gate, not the caveat.

Reviewed by adversarial-reviewer, two rounds (D19 cap), 15 findings, all
accepted, none declined. claims-auditor did not run — D20. Round 1's findings
were almost entirely in prose written that session, and round 2's blocking
finding plus its sharpest should-fix were both defects in round 1's own fixes.

public/pouya-lajevardi-bio.pdf is deliberately NOT in this commit. It was
regenerated and reverted: /bio/'s eyebrow sits inside `.no-print`, so the sheet
has no eyebrow at all, and `cmp -l` showed exactly 10 differing bytes, all in
/CreationDate and /ModDate.

Gates, every one read as an exit status and none through a pipe: check 0,
build 0 (22 pages), check:claims 0, og:proof 0, check:intake 0, lint 0,
minifier tripwire clean, TODO in dist 0. Lighthouse run twice with identical
output: perf 99 on / and 100 on the other 21, a11y 100, best practices 100,
SEO 100 on every indexable page, CLS 0.000, LCP 1.50–2.03s.

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

434 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. 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 `&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();
/* 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 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);
}