fix: refute (ar)'s intake finding; fix the D20 gloss class; add X-Robots-Tag on *.pdf
Build and deploy / build-and-deploy (push) Failing after 4s
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
b9523817e2
commit
02739adac9
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Applies the three distribution changes the site needs, as one reviewable
|
||||
* Applies the four distribution changes the site needs, as one reviewable
|
||||
* transaction. `docs/09-cutover-runbook.md` Part 3 is what calls it.
|
||||
*
|
||||
* 1. FunctionAssociations on the default behaviour -> `router.js`, viewer
|
||||
@@ -9,6 +9,9 @@
|
||||
* "the single most common misconfiguration in this stack".
|
||||
* 3. A `/api/*` cache behaviour on a new origin pointing at the HTTP API, so
|
||||
* the intake form's same-origin POST reaches the handler.
|
||||
* 4. A `*.pdf` cache behaviour carrying a response-headers policy that adds
|
||||
* `X-Robots-Tag: noindex`, so the bio PDF is not indexed as a duplicate of
|
||||
* `/bio/`. `docs/06`'s checklist item carries the reasoning.
|
||||
*
|
||||
* ⚠️ DRY RUN BY DEFAULT. It prints what it would change and exits 0 without
|
||||
* calling `update-distribution`. `--apply` is the only thing that writes, and it
|
||||
@@ -291,10 +294,303 @@ if (catchAll !== -1 && catchAll < apiIndex) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 4. X-Robots-Tag: noindex on the bio PDF ----------------------------
|
||||
⚠️ S3 OBJECT METADATA CANNOT DO THIS. `aws s3 sync --metadata` writes USER
|
||||
metadata, which S3 returns as `x-amz-meta-x-robots-tag` — a header no crawler
|
||||
reads. Only a literal `X-Robots-Tag` counts and the REST endpoint will not
|
||||
emit one, so the mechanism is a response-headers policy. `docs/06`'s checklist
|
||||
item carries why the PDF needs it at all; this comment carries only what the
|
||||
next implementer needs in order not to break it.
|
||||
|
||||
⚠️ THE ONE LIVE CONSTRAINT: A RESPONSE-HEADERS POLICY REPLACES, IT DOES NOT
|
||||
MERGE. Attaching a policy to `*.pdf` means the default behaviour's policy no
|
||||
longer applies there, so this one must carry everything that policy carries —
|
||||
hence the clone below, and hence the drift check that follows it. Measured
|
||||
2026-09-03: all five security headers arrive on the live PDF today. */
|
||||
const PDF_PATTERN = '*.pdf';
|
||||
const PDF_POLICY_NAME = 'adr-sml-pdf-noindex';
|
||||
const XRT = { Header: 'X-Robots-Tag', Value: 'noindex', Override: true };
|
||||
|
||||
const defaultRhpId = cfg.DefaultCacheBehavior.ResponseHeadersPolicyId;
|
||||
|
||||
function getResponseHeadersPolicy(id) {
|
||||
return aws([
|
||||
'cloudfront',
|
||||
'get-response-headers-policy',
|
||||
'--id',
|
||||
id,
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
}
|
||||
|
||||
/* Only `custom` is listed: `adr-sml-pdf-noindex` is a name this script creates,
|
||||
so a managed hit is impossible and listing them would be a wasted call that
|
||||
reads as if one were possible. */
|
||||
function findPdfPolicy() {
|
||||
const res = aws([
|
||||
'cloudfront',
|
||||
'list-response-headers-policies',
|
||||
'--type',
|
||||
'custom',
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
const items = res?.ResponseHeadersPolicyList?.Items ?? [];
|
||||
return (
|
||||
items.find(
|
||||
(i) =>
|
||||
i.ResponseHeadersPolicy.ResponseHeadersPolicyConfig.Name ===
|
||||
PDF_POLICY_NAME,
|
||||
)?.ResponseHeadersPolicy ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/* ⚠️ SKIP, DO NOT THROW. Sections 1–3 have already staged their mutations, and
|
||||
throwing here would make the script unusable for re-applying the router
|
||||
function or the 404 mapping — which is the re-run contract this file promises
|
||||
at the top, and `router.js` is what keeps 22 of 23 pages off S3's
|
||||
AccessDenied. A missing policy on the default behaviour is section 4's
|
||||
problem alone. */
|
||||
/* ⚠️ A SKIP IS NOT A CHANGE AND MUST NOT ENTER `changes`. That array is printed
|
||||
under "N change(s)", `docs/09` Part 3 tells the operator to COUNT those lines,
|
||||
and the `NOTHING TO CHANGE` guard exits on its length — so a skip in there
|
||||
would both miscount and send an `update-distribution` carrying a config
|
||||
nothing mutated. Skips get their own list and their own heading. */
|
||||
const skipped = [];
|
||||
if (!defaultRhpId) {
|
||||
skipped.push(
|
||||
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — the default behaviour has no ResponseHeadersPolicyId, so there is nothing to clone the security headers from`,
|
||||
);
|
||||
} else {
|
||||
const existingPdfPolicy = findPdfPolicy();
|
||||
let pdfPolicyId = existingPdfPolicy?.Id ?? null;
|
||||
|
||||
/* ⚠️ RECONCILE ON EVERY RUN, NEVER ONLY AT CREATION. The clone is a copy of a
|
||||
fact that lives somewhere else, so it goes stale the moment the default
|
||||
behaviour's policy changes — and it would go stale silently, as a uniform
|
||||
pass. `docs/05` already specifies a Content-Security-Policy (a field OF
|
||||
SecurityHeadersConfig) and a Permissions-Policy (which can only be a CUSTOM
|
||||
header) that the site does not ship yet; adding either to the default
|
||||
behaviour would reach the pages and not the PDF. This check fails loudly
|
||||
instead, naming the diff. */
|
||||
const source = getResponseHeadersPolicy(defaultRhpId);
|
||||
const srcCfg = source?.ResponseHeadersPolicy?.ResponseHeadersPolicyConfig;
|
||||
/* ⚠️ SKIP, NOT THROW — same rule as the missing-id case above, and it was
|
||||
inconsistent for one round. A policy carrying only `CorsConfig` is legal;
|
||||
an ABSENT source is section 4's problem alone and must not stop sections
|
||||
1-3 from re-applying `router.js`. The DRIFT throw below is different: that
|
||||
is a divergence, not an absence, and `docs/09` Part 3 argues for it. */
|
||||
if (!srcCfg?.SecurityHeadersConfig) {
|
||||
skipped.push(
|
||||
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`,
|
||||
);
|
||||
} else {
|
||||
const wanted = {
|
||||
SecurityHeadersConfig: srcCfg.SecurityHeadersConfig,
|
||||
...(srcCfg.CorsConfig ? { CorsConfig: srcCfg.CorsConfig } : {}),
|
||||
...(srcCfg.RemoveHeadersConfig
|
||||
? { RemoveHeadersConfig: srcCfg.RemoveHeadersConfig }
|
||||
: {}),
|
||||
...(srcCfg.ServerTimingHeadersConfig
|
||||
? { ServerTimingHeadersConfig: srcCfg.ServerTimingHeadersConfig }
|
||||
: {}),
|
||||
CustomHeadersConfig: {
|
||||
Quantity: (srcCfg.CustomHeadersConfig?.Items ?? []).length + 1,
|
||||
Items: [...(srcCfg.CustomHeadersConfig?.Items ?? []), XRT],
|
||||
},
|
||||
};
|
||||
|
||||
if (existingPdfPolicy) {
|
||||
const have = existingPdfPolicy.ResponseHeadersPolicyConfig;
|
||||
const norm = (o) => JSON.stringify(o ?? null);
|
||||
const drift = [
|
||||
'SecurityHeadersConfig',
|
||||
'CorsConfig',
|
||||
'RemoveHeadersConfig',
|
||||
'ServerTimingHeadersConfig',
|
||||
]
|
||||
.filter((k) => norm(have[k]) !== norm(wanted[k]))
|
||||
.concat(
|
||||
norm(have.CustomHeadersConfig?.Items) !==
|
||||
norm(wanted.CustomHeadersConfig.Items)
|
||||
? ['CustomHeadersConfig']
|
||||
: [],
|
||||
);
|
||||
if (drift.length) {
|
||||
/* Print BOTH SIDES of every drifted key. Naming the field alone does not
|
||||
tell the operator which header moved, nor which direction to reconcile
|
||||
in — the same message fires whether the source gained a header or the
|
||||
PDF policy lost its X-Robots-Tag, and those need opposite repairs. */
|
||||
const detail = drift
|
||||
.map(
|
||||
(k) =>
|
||||
` ${k}\n pdf policy : ${norm(
|
||||
k === 'CustomHeadersConfig'
|
||||
? have.CustomHeadersConfig?.Items
|
||||
: have[k],
|
||||
)}\n default : ${norm(
|
||||
k === 'CustomHeadersConfig'
|
||||
? wanted.CustomHeadersConfig.Items
|
||||
: wanted[k],
|
||||
)}`,
|
||||
)
|
||||
.join('\n');
|
||||
throw new Error(
|
||||
`${PDF_POLICY_NAME} has DRIFTED from the default behaviour's policy ` +
|
||||
`${defaultRhpId} on ${drift.length} field(s). The PDF is being served ` +
|
||||
`different headers from the pages — read which way before repairing:\n` +
|
||||
`${detail}\n` +
|
||||
`Reconcile with update-response-headers-policy (it needs the policy's ` +
|
||||
`own ETag), then re-run. This script will not silently paper over it.`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`· response-headers policy ${PDF_POLICY_NAME} exists and matches the default behaviour`,
|
||||
);
|
||||
} else if (!APPLY) {
|
||||
console.log(`· would CREATE response-headers policy ${PDF_POLICY_NAME}`);
|
||||
changes.push(
|
||||
`create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`,
|
||||
);
|
||||
} else {
|
||||
const created = aws([
|
||||
'cloudfront',
|
||||
'create-response-headers-policy',
|
||||
'--response-headers-policy-config',
|
||||
JSON.stringify({
|
||||
Name: PDF_POLICY_NAME,
|
||||
Comment:
|
||||
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.',
|
||||
...wanted,
|
||||
}),
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
pdfPolicyId = created?.ResponseHeadersPolicy?.Id;
|
||||
if (!pdfPolicyId) {
|
||||
throw new Error('create-response-headers-policy returned no Id');
|
||||
}
|
||||
console.log(
|
||||
`created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`,
|
||||
);
|
||||
changes.push(
|
||||
`created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pdfBehaviours = cfg.CacheBehaviors?.Items ?? [];
|
||||
const foundPdf = pdfBehaviours.find((b) => b.PathPattern === PDF_PATTERN);
|
||||
if (foundPdf) {
|
||||
/* ⚠️ PRESENCE IS NOT CORRECTNESS. This checked only that a `*.pdf`
|
||||
behaviour existed, so one added by hand — while chasing the
|
||||
`aws s3 sync --metadata` route this file's header records as the original
|
||||
instruction — would report `already present`, push nothing, and print
|
||||
NOTHING TO CHANGE while the PDF served no `X-Robots-Tag` at all. Section 1
|
||||
compares the FunctionARN before declaring a match; so does this now. */
|
||||
const wrong = [];
|
||||
if (foundPdf.ResponseHeadersPolicyId !== pdfPolicyId) {
|
||||
wrong.push(
|
||||
`ResponseHeadersPolicyId is ${foundPdf.ResponseHeadersPolicyId ?? '(none)'}, expected ${pdfPolicyId ?? '(the policy this script manages)'}`,
|
||||
);
|
||||
}
|
||||
if (foundPdf.TargetOriginId !== cfg.DefaultCacheBehavior.TargetOriginId) {
|
||||
wrong.push(
|
||||
`TargetOriginId is ${foundPdf.TargetOriginId}, expected ${cfg.DefaultCacheBehavior.TargetOriginId}`,
|
||||
);
|
||||
}
|
||||
const hasViewerRequest = (
|
||||
foundPdf.FunctionAssociations?.Items ?? []
|
||||
).some((i) => i.EventType === 'viewer-request');
|
||||
if (!hasViewerRequest) {
|
||||
wrong.push(
|
||||
'no viewer-request FunctionAssociation — router.js normalises `//` and `\\` on file paths, so `//pouya-lajevardi-bio.pdf` would 404 instead of 301',
|
||||
);
|
||||
}
|
||||
if (wrong.length) {
|
||||
throw new Error(
|
||||
`a ${PDF_PATTERN} cache behaviour already exists but is NOT the one this ` +
|
||||
`script manages:\n - ${wrong.join('\n - ')}\n` +
|
||||
`Reconcile or remove it before re-running; this script will not adopt ` +
|
||||
`a behaviour it cannot account for.`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`· cache behaviour ${PDF_PATTERN} already present and correctly configured`,
|
||||
);
|
||||
} else {
|
||||
const d = cfg.DefaultCacheBehavior;
|
||||
const behaviour = {
|
||||
PathPattern: PDF_PATTERN,
|
||||
TargetOriginId: d.TargetOriginId,
|
||||
ViewerProtocolPolicy: d.ViewerProtocolPolicy,
|
||||
AllowedMethods: d.AllowedMethods,
|
||||
CachePolicyId: d.CachePolicyId,
|
||||
/* Placeholder only in a dry run — the real id exists by the time --apply
|
||||
reaches this line, because the branch above created it. */
|
||||
ResponseHeadersPolicyId: pdfPolicyId ?? '<created on --apply>',
|
||||
Compress: d.Compress,
|
||||
SmoothStreaming: false,
|
||||
FieldLevelEncryptionId: '',
|
||||
/* ⚠️ THE ROUTER FUNCTION IS ATTACHED, AND IT IS NOT A NO-OP ON FILE PATHS.
|
||||
`router.js` normalises `\` to `/` and collapses a leading `//` run
|
||||
BEFORE it tests for an extension, and 301s when normalisation changed
|
||||
anything — so `//pouya-lajevardi-bio.pdf` redirects to the canonical
|
||||
path today. Omitting the association here would silently drop that and
|
||||
hand S3 the doubled key instead. The `/api/*` reason for omitting it —
|
||||
a 301 turning a POST into a GET and losing the body — does not apply to
|
||||
a GET-only PDF. */
|
||||
FunctionAssociations: d.FunctionAssociations ?? { Quantity: 0 },
|
||||
LambdaFunctionAssociations: { Quantity: 0 },
|
||||
TrustedKeyGroups: { Enabled: false, Quantity: 0 },
|
||||
};
|
||||
/* ⚠️ STAGE THE REPORT EVEN WHEN THE ID IS NOT KNOWN YET. The dry run's whole
|
||||
job is to show what would touch a distribution serving 23 pages; reporting
|
||||
only the harmless policy creation and staying silent about the behaviour
|
||||
would mean the first sight of it is `update-distribution` writing it. The
|
||||
`cfg` mutation stays gated on a real id; the REPORT does not. */
|
||||
changes.push(
|
||||
`CacheBehaviors += ${PDF_PATTERN} -> ${d.TargetOriginId}, default cache policy, ${PDF_POLICY_NAME}` +
|
||||
(pdfPolicyId
|
||||
? ` (${pdfPolicyId})`
|
||||
: ' (policy id created in the same --apply pass)'),
|
||||
);
|
||||
if (!APPLY && !pdfPolicyId) {
|
||||
console.log(
|
||||
`· would ADD cache behaviour ${PDF_PATTERN}:\n` +
|
||||
JSON.stringify(behaviour, null, 2)
|
||||
.split('\n')
|
||||
.map((l) => ' ' + l)
|
||||
.join('\n'),
|
||||
);
|
||||
} else {
|
||||
pdfBehaviours.push(behaviour);
|
||||
cfg.CacheBehaviors = {
|
||||
Quantity: pdfBehaviours.length,
|
||||
Items: pdfBehaviours,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
/* Skips print under their own heading and are NOT counted as changes — see the
|
||||
comment on `skipped`. A skip means section 4 did nothing and the PDF is
|
||||
probably not noindexed; that is louder than a silent omission and quieter
|
||||
than a false change. */
|
||||
if (skipped.length) {
|
||||
console.log(`⚠ ${skipped.length} thing(s) SKIPPED, not changed:`);
|
||||
for (const k of skipped) console.log(` ! ${k}`);
|
||||
console.log(' Sections 1-3 are unaffected. Investigate before relying on');
|
||||
console.log(` ${PDF_PATTERN} carrying X-Robots-Tag.`);
|
||||
console.log('');
|
||||
}
|
||||
if (changes.length === 0) {
|
||||
console.log(
|
||||
'NOTHING TO CHANGE — the distribution already carries all three.',
|
||||
skipped.length
|
||||
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all four.'
|
||||
: 'NOTHING TO CHANGE — the distribution already carries all four.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user