Steps 7a through 10 as one authorised run. Nothing deployed (D11).
7a Lighthouse returns as `lighthouse@13.4.1` + `chrome-launcher`, NOT
`@lhci/cli`. AGENTS.md §7's advisory attribution was wrong: the carriers
were @lhci/cli's own `tmp` and @puppeteer/browsers' `extract-zip`, not
Lighthouse, which audits clean. A deliberate deviation from R11's literal
trigger, recorded with what it costs. Local gate; CI has no Chrome.
7b OG card generator (satori + sharp) discharges R15 — 20 typed cards plus
per-article cards; the portrait stays on / and /about/ by Q40. Insights
plumbing: ArticleCard, Prose, the index, the article route, articleGraph,
and /'s section 7. Card copy is constrained structurally because text in a
JPEG cannot be grepped by check:claims: every headline IS its page's <h1>,
enforced by `npm run og:proof`.
7c Five drafted launch articles, draft: true / reviewedByPouya: false. An
independent compliance audit returned 76 findings and 57 unsourced
assertions; all blocking and should-fix applied.
8 /contact/, the intake form, and backend/intake/ (undeployed). Plain HTML
POST to a same-origin /api/intake with a 303 redirect, so the form works
with zero JavaScript. docs/05 records three deliberate deviations.
9 /fees/ on Q59's ruling — overtime runs from the session cap, and the
reservation point ships adjacent to the rate. One-page PDF bio discharges
R16; /bio/ is its source, so the circulated artefact stays inside the
review apparatus.
10 /legal/privacy/ and /legal/terms/, written to the backend as built. Three
of the policy's statements are derived and cannot drift.
Also: /about/'s inverse credentials band (approved at step 6); Q59 closed;
R15 and R16 discharged; and a fix to shipped copy — /practice/energy/ asserted
the absence of a regulation the source extract says must not be asserted.
Review: adversarial-reviewer, two rounds (D20/D19). Round 1 returned 16
findings including two blocking — an invisible ghost button on /fees/ at
1.00:1 that Lighthouse scored 100, and a privacy policy that named one data
processor when there are two. All 16 acted on.
Lighthouse, 22 pages, mobile: performance 99-100, accessibility 100,
best practices 100, SEO 100 on every indexable page, CLS 0.000.
AGENTS.md entry (ah) has the detail, including four of my own verification
commands that were wrong and what each of them nearly caused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
424 lines
17 KiB
JavaScript
424 lines
17 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`; `docs/06` names it.
|
|
*/
|
|
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}`);
|
|
}
|
|
|
|
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);
|
|
}
|