feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Build and deploy / build-and-deploy (push) Failing after 4s
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:
co-authored by
Claude Opus 5
parent
ca1c2524e1
commit
bd282aa47d
+61
-14
@@ -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`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user