feat: build steps 7a-10 — the site is complete and reviewable at 22 pages

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
This commit is contained in:
Pouya Lajevardi
2026-08-31 10:56:54 -04:00
co-authored by Claude Opus 5
parent 6cfe69033f
commit 210bc25a26
53 changed files with 8589 additions and 177 deletions
+121 -9
View File
@@ -16,6 +16,98 @@ a verified sender on `smlcompany.ca`. `[verified 2026-08-26 — AGENTS.md §7]`
The shape is right. This is a hardening and rework pass, not a replacement.
---
## Build step 8, as actually built — 2026-08-31
**What is in the repository:** `/contact/` with the intake form, two
POST-redirect-GET landing pages, and `backend/intake/handler.mjs` +
`backend/intake/fields.mjs` — the handler that **replaces** the hand-built
`adr-intake-handler` §7 records.
**What is NOT done, and the form does not work until it is.** Nothing on this
project deploys before cutover (D11), so: the handler is not deployed, and the
**CloudFront `/api/*` behaviour the form posts to does not exist**. Both are on
`docs/06`'s cutover checklist. `/contact/` publishes the email address as well
as the form for exactly this reason.
### The form is a plain HTML POST, and it answers 303
The site ships **zero** JavaScript (§7 — none, not "minimal"), so the form is a
`<form method="post">` and the handler replies **303 See Other** to a page on the
site. That buys three things with no script anywhere: it works with JavaScript
disabled, which is the failure this whole project exists to fix; the visitor never
sees a raw JSON body rendered as a page; and a refresh cannot resubmit, because
the browser lands on a GET.
Two pages exist for the two outcomes — `/contact/received/` and
`/contact/could-not-send/`. Both are `noindex` and both are excluded from the
sitemap in `astro.config.mjs`. **The failure page names no field**, because the
handler deliberately does not return the error list (an enumeration of the
validation rules is a gift to whoever is probing them) and because a static page
cannot read `?error=` without script.
### It posts to `/api/intake`, not to the execute-api hostname
Same-origin, with a CloudFront behaviour routing `/api/*` to the HTTP API origin
§7 records. Four consequences, and the fourth is the one that matters day to day:
`form-action 'self'` alone satisfies the CSP below; there is no cross-origin POST
to reason about; the endpoint id stays out of the HTML and out of the repo; and
**submitting the form from `astro dev` does nothing**, because there is no
`/api/` route locally. Under the alternative, clicking Submit on a laptop would
write a real DynamoDB record and send two real emails.
### ⚠️ Three deviations from this spec, each deliberate
**1. The 3-second timestamp check is NOT implemented.** It cannot be, and
implementing it would produce a control that does nothing. The check needs to know
when the form was *served to that visitor*; `/contact/` is a static file cached at
the CloudFront edge, so a build-time timestamp is the same value for every visitor
and is hours or days old. `now served` is therefore always large, and the check
passes for a bot exactly as it passes for a human. A per-visitor token needs a
dynamic origin or client-side script, and the site has neither by design.
A control that exists on paper and not in fact is worse than a stated gap — that
is what `AGENTS.md` Q22 and the Lighthouse row both cost. So it is omitted and
said out loud, and the load is carried by the honeypot, the `Origin` check, the
API Gateway rate limit and server-side validation.
**2. CORS is not what protects the form, and the `Origin` check is.** A form POST
is a top-level navigation: it is exempt from CORS preflight, so an
`Access-Control-Allow-Origin` setting cannot stop another site posting a form
here. The handler compares `Origin` (falling back to `Referer`, which Firefox
sends where it omits `Origin`) against the site origin and refuses anything else.
The CORS restriction in this spec is still right — it governs *scripted* calls to
the endpoint — but it is a different control and was being relied on for this one.
**3. There is no `mailto:` fallback, because there is nothing to fall back FROM.**
This spec's definition of done asks that the form "degrades to a `mailto:`
fallback with JavaScript disabled". The form never used script, so it does not
degrade. The email address is published on `/contact/` regardless, and the failure
page routes to it.
### Two field tables, cross-checked
`src/data/intake.ts` builds the form. `backend/intake/fields.mjs` is what the
handler validates against. **The duplication is architectural**, because this
spec's own rule is that the Lambda re-validates everything: a server validating
against a list the client shipped it is asking the caller what the rules are. And
the Lambda is a separately deployed zip that cannot import from `src/`.
**`npm run check:intake` is what keeps them honest** — it imports both and asserts
they agree on every field name, on which are required, on every length cap, and on
every closed option set. Probed with three deliberate mismatches (a changed cap, a
dropped field, a changed option); each was caught, exit 1.
### Analytics: decided, not installed
D15 chose Plausible. **§7 records that no script is on any page**, and
`ANALYTICS.installed` in `src/data/site.ts` is `false`. `/legal/privacy/` renders
its analytics paragraph from that flag, so today the policy says the site sets no
cookies and runs no analytics — which is the fact. **Flipping the flag is a change
to a published disclosure**, not a config edit: the policy changes on the same
build and its last-updated date moves with it.
## What this data actually is
The form collects, in a live legal dispute: the inquirer's identity and contact
@@ -192,6 +284,16 @@ DLQ depth ≥ 1.
## Booking
**PARKED — R6, and `/contact/` ships without it.** Pouya parked the booking tool
on 2026-08-26; build step 8 shipped the form and no embed. The "reserved slot"
`docs/01` asks for is `CONTACT.bookingUrl` being `null`: nothing renders, and a
URL there brings the block back without a rebuild of the page.
**Nothing on `/contact/` mentions booking**, deliberately — a page that says
"book a call" with no way to book one is worse than a page that says to email.
D10 committed to booking because it removes the back-and-forth that loses
appointments, so the form alone is a partial answer and R6 stays live.
An embedded scheduler for the 3045 minute confidential intake call
(**Q5** — tool not yet chosen).
@@ -231,6 +333,13 @@ Content-Security-Policy: default-src 'self'; img-src 'self' data:;
base-uri 'self'; frame-ancestors 'none'
```
⚠️ **`form-action` IS NOW `'self'` ALONE, and that is tighter than the line
above.** Build step 8 posts the intake form to the same-origin path `/api/intake`
rather than to the execute-api hostname, so no third-party origin needs to appear
in the policy. Drop `<api-endpoint>` from `form-action` when the policy is
written. `frame-src <booking-provider>` is also unnecessary while R6 keeps the
embed parked — add it with the embed, not before.
Tighten CSP once the booking provider is chosen. `unsafe-inline` on styles is
tolerable for critical CSS; `unsafe-inline` on scripts is not — use a hash or
nonce for the reveal script.
@@ -253,13 +362,16 @@ Plausible or Fathom, cookieless, no consent banner.
## Definition of done
- [ ] Server-side validation independent of the client
- [ ] Honeypot and timing checks live; rate limit configured
- [ ] CORS restricted to the production origin
- [ ] TTL set and verified by test record
- [ ] KMS encryption and PITR enabled
- [x] **Server-side validation independent of the client**`backend/intake/fields.mjs`, cross-checked by `npm run check:intake`
- [x] **Honeypot live.** ⚠️ **The timing check is NOT implemented** — see deviation 1 above; it is unimplementable on a CDN-cached static page and would be a control that does nothing
- [ ] **Rate limit configured** — API Gateway throttling, 5 requests / 5 minutes per source IP. Not expressible in handler code; not done
- [x] **The form's own protection is the `Origin` check, not CORS** — see deviation 2. CORS on the endpoint still to be restricted for scripted calls
- [ ] **TTL set and verified by test record.** ⚠️ **THIS ONE BACKS A PUBLISHED PROMISE.** `/legal/privacy/` states that records are deleted automatically after 24 months. The handler writes the `ttl` attribute; TTL must also be **enabled on the table**, and §7 does not record that it is. Until this is verified the page promises a mechanism that may not run
- [ ] KMS customer-managed key and PITR enabled. **Neither is claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is true of every DynamoDB table unconditionally, and does not mention either of these because §7 does not verify them
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
- [ ] DLQ and CloudWatch alarm configured
- [ ] Form usable by keyboard only; errors announced with `role="alert"`
- [ ] Form degrades to a `mailto:` fallback with JavaScript disabled
- [ ] Privacy policy matches the implementation line for line
- [ ] DLQ and CloudWatch alarm configured. The handler writes to DynamoDB **before** sending mail, so a replay cannot lose a submission
- [x] **Form usable by keyboard only.** Errors are announced by the browser's own validation, which with no script is the only thing that can announce them inline — `role="alert"` needs a live region and something to write into it
- [x] **Works with JavaScript disabled** — replacing the `mailto:` degradation item; see deviation 3
- [x] **Privacy policy matches the implementation** — and three of its statements are DERIVED rather than written, so they cannot drift: the collected-data list renders from `INTAKE_FIELDS`, the retention period from the handler's own figure, and the analytics paragraph from `ANALYTICS.installed`
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it
- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM` and `RESPONSE_TIME` set. It throws at cold start on any missing one, deliberately