Build and deploy / build-and-deploy (push) Failing after 4s
Pouya's rulings of 2026-09-03, in five parts. 1. THE INTAKE FORM IS NOT BROKEN. (ar) was wrong. docs/09 §7.1 verbatim — POST /api/intake with an Origin header — returns 303 to /contact/could-not-send/ with access-control-allow-origin echoed; the same probe without Origin returns 403. A bare POST 403s BY DESIGN and §7.1 says so three lines below the probe it prescribes: "403 means the Origin header did not arrive". The earlier finding read a status code without reading the document that defines it. Second time in two days. CLAUDE.md's instrument list goes eight to nine. D20 findings 12 and 19 fall with it; §7.2 (that both emails arrive) is still owed. The correction is APPENDED as entry (as); (ar) stands unedited. 2. The privacy retention comment was stale, not a defect — superseded by his decision to publish and confirm after launch, reading from 2026-09-04. Reworded; the TODO(pouya) came off with the gate it enforced. The mechanism finding survives: it was a JSX comment, stripped by Astro, so no build or deploy path could see it. A publication gate that lives only in a stripped comment is not a gate. §9 Q60 corrected. 3. The gloss class is fixed — 15 of the 20 D20 findings, 14 distinct edits across 9 files, under the rule "the gloss may say no more than the extract says; no new claims, no new sources". Swept three unpublished insights drafts too, and corrected the wrong CAA attribution at its source in docs/reference/, which is where a fixed page re-seeds. /bio/ changed, so the committed PDF is regenerated (89,549 B, 1 page asserted). Three findings outstanding: 10 needs a ruling, 11 is ruled and owed via Q60, 13 needs him to have said it. R1 is not one of the twenty. 4. X-Robots-Tag cannot be done with S3 object metadata — --metadata writes user metadata, returned as x-amz-meta-x-robots-tag, which no crawler reads. Built as the CloudFront response-headers policy docs/06 has specified all along: configure.mjs section 4. It needs a --apply run, not a deploy. The policy is cloned from whatever is attached at run time and reconciled on every run, because a response-headers policy replaces rather than merges. 5. Headshot deferred as an open non-defect. The master and the srcset ladder are both fine; Astro passes no quality, so AVIF encodes at sharp's default 50 and is served first. Two review rounds, 29 findings, all resolved, none declined; stopped at two per D19. NINE of round 2's fourteen were defects in round 1's own repairs — including a fix that harmonised both /fees/ rows onto wording that was itself unregistered, publishing an unsourced fee term twice where it had been once. Gates, exit status read for each: check 0 (0 errors, 0 warnings, 0 hints), build 0 (23 pages), check:claims 0, check:intake 0, og:proof 0, lint 0, minifier grep exit 1, router.test.mjs 30/30. Lighthouse NOT run. Nothing deployed and nothing applied to the distribution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
120 lines
5.3 KiB
JavaScript
120 lines
5.3 KiB
JavaScript
/**
|
|
* CloudFront Function, VIEWER REQUEST, on the DEFAULT behaviour and on `*.pdf`.
|
|
* Not on `/api/*` — see the rule below, which is the one that matters.
|
|
*
|
|
* `*.pdf` has it because this function is NOT a no-op on file paths: it
|
|
* normalises `\` to `/` and collapses a leading `//` run BEFORE the extension
|
|
* test, and 301s when that changed anything. Measured live 2026-09-03:
|
|
* `//pouya-lajevardi-bio.pdf` returns 301. Dropping the association there hands
|
|
* S3 the doubled key and returns 404 instead.
|
|
*
|
|
* ⚠️ 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' },
|
|
},
|
|
};
|
|
}
|