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
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* CloudFront Function, VIEWER REQUEST, on the default cache behaviour only.
|
||||
*
|
||||
* ⚠️ THE SITE DOES NOT WORK WITHOUT THIS. `astro.config.mjs` sets
|
||||
* `trailingSlash: 'always'` and `build.format: 'directory'`, so every route is
|
||||
* `<dir>/index.html`. CloudFront forwards the viewer path to the S3 REST origin
|
||||
* unchanged, S3 has no key `about/`, and the request fails. Measured on the live
|
||||
* distribution 2026-09-01, before this function existed: `/about/` and
|
||||
* `/definitely-not-a-page/` both returned **403 with an 111-byte
|
||||
* `application/xml` body** — S3's AccessDenied, served raw to the reader. Only
|
||||
* `/` worked, via the distribution's default root object. That is 22 of the 23
|
||||
* pages.
|
||||
*
|
||||
* ⚠️ DO NOT ASSOCIATE IT WITH THE `/api/*` BEHAVIOUR. The intake path
|
||||
* `/api/intake` has no extension and no trailing slash, so the redirect branch
|
||||
* below would answer a form POST with a 301 — and a 301 turns a POST into a GET,
|
||||
* which would lose the submission body silently. The association is per
|
||||
* behaviour and `/api/*` gets none.
|
||||
*
|
||||
* Two rules, and the second is a `docs/04` requirement rather than a nicety:
|
||||
*
|
||||
* /about/ -> rewrite to /about/index.html (the origin has that key)
|
||||
* /about -> 301 to /about/ (one canonical URL per page)
|
||||
*
|
||||
* Anything with a file extension in its last segment is left alone —
|
||||
* `robots.txt`, `sitemap-0.xml`, `/_astro/*`, `/fonts/*`, `/og/*.jpg`,
|
||||
* `favicon.ico`, `pouya-lajevardi-bio.pdf`, and `404.html` itself.
|
||||
*
|
||||
* Written to the `cloudfront-js-2.0` runtime and deliberately conservative: no
|
||||
* arrow functions, no `String.prototype.endsWith`, no template literals. The
|
||||
* runtime supports more than this; a viewer-request function runs on every
|
||||
* request to the site and is the wrong place to be clever.
|
||||
*/
|
||||
/* The header-injection surface, and nothing else: C0 controls, DEL, space, and
|
||||
WHATWG's query percent-encode set (`"`, `#`, `<`, `>`). `#` is in because it
|
||||
changes the STRUCTURE of the Location — left in, `?a=x#&b=y` drops `&b=y` into
|
||||
a fragment. `| ^ ` { }` are NOT in, and must not be added: browsers send them
|
||||
raw and `|` is routine in tracking values. Strip rather than encode — these
|
||||
values arrive percent-encoded, so encoding again makes `%20` into `%2520`. */
|
||||
function safe(part) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return String(part).replace(/[\u0000-\u0020\u007f"<>#]/g, '');
|
||||
}
|
||||
|
||||
function handler(event) {
|
||||
var request = event.request;
|
||||
|
||||
/* ⚠️ NORMALISE, THEN REDIRECT IF ANYTHING CHANGED. Leading `//` and `\` are
|
||||
collapsed because CloudFront forwards duplicate slashes verbatim (it resolves
|
||||
dot-segments; it does not collapse `//`) and `Location: //host/x` is a
|
||||
network-path reference that REPLACES THE AUTHORITY — RFC 3986 s4.2. `\` does
|
||||
the same, because the URL Standard maps it to `/` in special schemes.
|
||||
Redirect rather than rewrite, or `//about/` serves the About page at a second
|
||||
URL with a 200. Only the leading run: an interior `//` is a key that does not
|
||||
exist. */
|
||||
var uri = request.uri.replace(/\\/g, '/').replace(/^\/+/, '/');
|
||||
var normalised = uri !== request.uri;
|
||||
var lastSlash = uri.lastIndexOf('/');
|
||||
var lastSegment = uri.substring(lastSlash + 1);
|
||||
|
||||
// A file, not a route.
|
||||
if (lastSegment.indexOf('.') !== -1) {
|
||||
if (normalised) return moved(uri, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
// A directory-style route: hand the origin the key it actually holds.
|
||||
if (lastSegment === '') {
|
||||
if (normalised) return moved(uri, request);
|
||||
request.uri = uri + 'index.html';
|
||||
return request;
|
||||
}
|
||||
|
||||
/* Extensionless and no trailing slash. Redirect rather than rewrite, so the
|
||||
page has ONE address: serving it at both would put two indexable URLs on the
|
||||
same content, which `docs/04` treats as its primary concern. */
|
||||
return moved(uri + '/', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 301 to a path on this origin, carrying the query string. `location` is always
|
||||
* built from an already-normalised path, which is what keeps it same-origin.
|
||||
*/
|
||||
function moved(path, request) {
|
||||
var qs = '';
|
||||
var names = Object.keys(request.querystring);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var name = names[i];
|
||||
var value = request.querystring[name];
|
||||
if (value.multiValue) {
|
||||
for (var j = 0; j < value.multiValue.length; j++) {
|
||||
qs +=
|
||||
(qs === '' ? '' : '&') +
|
||||
safe(name) +
|
||||
'=' +
|
||||
safe(value.multiValue[j].value);
|
||||
}
|
||||
} else {
|
||||
/* Always `name=value`, so `?ref` and `?ref=` normalise to one form rather
|
||||
than the function guessing which the viewer meant. */
|
||||
qs += (qs === '' ? '' : '&') + safe(name) + '=' + safe(value.value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
statusCode: 301,
|
||||
statusDescription: 'Moved Permanently',
|
||||
headers: {
|
||||
location: { value: path + (qs === '' ? '' : '?' + qs) },
|
||||
'cache-control': { value: 'public, max-age=0, must-revalidate' },
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user