Files
Pouya LajevardiandClaude Opus 5 02739adac9
Build and deploy / build-and-deploy (push) Failing after 4s
fix: refute (ar)'s intake finding; fix the D20 gloss class; add X-Robots-Tag on *.pdf
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
2026-09-03 17:23:20 -04:00

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' },
},
};
}