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
113 lines
4.9 KiB
JavaScript
113 lines
4.9 KiB
JavaScript
/**
|
|
* 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' },
|
|
},
|
|
};
|
|
}
|