feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Build and deploy / build-and-deploy (push) Failing after 4s

Five items of Pouya's production run, 2026-09-01.

Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.

Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.

Four launch blockers found by reading the running system:
  - handler.mjs wrote pk/sk; the live table's key is submissionId with no
    sort key, so every submission would have failed validation silently
  - the Lambda invoke permission is scoped to the old route path
  - 22 of 23 pages 403 without the router function
  - there was no 404 page; src/pages/404.astro adds it

Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.

Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-09-02 06:52:20 -04:00
co-authored by Claude Opus 5
parent ca1c2524e1
commit bd282aa47d
30 changed files with 3256 additions and 143 deletions
+61 -14
View File
@@ -43,18 +43,24 @@
* 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.
* 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 ──────────────────────────────
*
* - API Gateway throttling, 5 requests / 5 minutes per source IP (docs/05).
* Not expressible in handler code.
* - 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.
* - 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.
* - 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';
@@ -199,6 +205,33 @@ function parseBody(event) {
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
@@ -323,20 +356,31 @@ export async function handler(event) {
/**
* 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.
* 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: {
pk: { S: `INTAKE#${id}` },
sk: { S: now.toISOString() },
/* ⚠️ `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.
sourceIp: { S: event.requestContext?.http?.sourceIp ?? 'unknown' },
/* 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),
},
@@ -378,7 +422,10 @@ export async function handler(event) {
Subject: { Data: `Intake — ${clean.name} (${clean.practiceArea})` },
Body: {
Text: {
Data: `Received ${now.toISOString()}\nRecord INTAKE#${id}\n\n${summaryLines}\n`,
// 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`,
},
},
},