Build and deploy / build-and-deploy (push) Failing after 4s
The third --apply of 2026-09-04 reached update-distribution and was rejected atomically: "Distributions with the Free pricing plan can't have the following features: Custom origin request policy, Custom response headers policy." Pouya's ruling: both are PARKED as unavailable — a platform constraint, not a defect. The pre-flight added in the previous commit could not have caught this, and that is the point: every limit in PAYLOAD_LIMITS is a property of the payload, while this is a property of the account, reported only by the call the pre-flight exists to avoid. Both sections now stop before creating anything. The plan is not in the CloudFront API — checked across 167 operations, no operation, shape, member or documentation string mentions one, and PriceClass_All is the edge-location price class, not the plan. So the gate is a constant, PLAN_ALLOWS_CUSTOM_POLICIES, and the two sections report as PARKED under their own heading rather than as skips: the previous commit made a skip exit 3, and a constraint true on every run would have made 3 permanent. Proven with a shim that refuses every mutating verb: --apply now makes zero of them. Substitute (a): Disallow: /pouya-lajevardi-bio.pdf in robots.txt, placed before Allow:/ so first-match crawlers honour it too. It is not an equivalent and the file says so — it stops the PDF being fetched, solving the duplicate-of-/bio/ problem, but does not de-index a URL linked from /bio/ and /about/. Verified: syntax, a match simulation under both crawler semantics, and that the sitemap does not list the PDF. Substitute (b): the WAF web ACL CreatedByCloudFront-f8fbf256 is already attached — 925 WCU, three AWS managed rule groups, no rate-based statement. That corrects §9 Q65, which framed WAF as a cost decision about adding one and named the now-unappliable header forwarding as its groundwork. The real question is one rule on an ACL already paid for, and a rate-based rule matches the viewer address directly, so the capability is superseded rather than lost. Reviewed in two rounds by me rather than a separate agent, per instruction. Nothing was applied to the distribution and nothing was deployed; robots.txt needs one site deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
1276 lines
57 KiB
JavaScript
1276 lines
57 KiB
JavaScript
/**
|
||
* Applies the distribution changes the site needs, as one reviewable
|
||
* transaction. ⚠️ **THREE OF THE FIVE ARE APPLICABLE; 4 AND 5 ARE PARKED** —
|
||
* the pricing plan forbids both a custom response headers policy and a custom
|
||
* origin request policy, so they are reported and skipped rather than attempted.
|
||
* See `PLAN_ALLOWS_CUSTOM_POLICIES` below and `AGENTS.md` §7. `docs/09-cutover-runbook.md` Part 3 is what calls it.
|
||
*
|
||
* 1. FunctionAssociations on the default behaviour -> `router.js`, viewer
|
||
* request. Without it 22 of 23 pages return S3's AccessDenied XML.
|
||
* 2. CustomErrorResponses: 404 -> /404.html with response code 404.
|
||
* `docs/04` requires a genuine 404 status; `docs/06` calls a 200 here
|
||
* "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.
|
||
* 5. A custom origin request policy on `/api/*` forwarding
|
||
* `CloudFront-Viewer-Address` — the only address CloudFront generates and
|
||
* overwrites, so the only one that could ever support a per-IP measure.
|
||
* ⚠️ THE ONLY ITEM HERE THAT REPLACES RATHER THAN ADDS, and it replaces
|
||
* the policy on the path the intake form posts to. `docs/09` Part 3's
|
||
* verification block runs after it, not optionally.
|
||
*
|
||
* ⚠️ 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
|
||
* sends the `IfMatch` ETag it read, so a concurrent console edit fails the call
|
||
* rather than being overwritten.
|
||
*
|
||
* ⚠️ IDEMPOTENT ON PURPOSE. Every change is checked for before it is made, so a
|
||
* re-run after a partial failure completes the rest instead of adding a second
|
||
* `/api/*` behaviour. Re-running a runbook step is the normal case, not the
|
||
* exception.
|
||
*
|
||
* ⚠️ NO MANAGED POLICY ID IS WRITTEN IN THIS FILE. They are resolved by name
|
||
* from the account at run time — `CLAUDE.md`'s rule that a pin is verified
|
||
* against the registry rather than recalled applies to an AWS identifier just
|
||
* as much as to an npm version, and a wrong cache-policy id here would ship a
|
||
* cached POST endpoint.
|
||
*
|
||
* usage:
|
||
* node infra/cloudfront/configure.mjs --dist <id> --api-domain <host> [--function-arn <arn>]
|
||
* node infra/cloudfront/configure.mjs ... --apply
|
||
*/
|
||
import { execFileSync } from 'node:child_process';
|
||
import { readFileSync } from 'node:fs';
|
||
/* Section 4's clone, and why an empty member is always AWS's placeholder
|
||
rather than a value: `policy-shapes.mjs`. Tested there, because this file
|
||
reads argv and calls AWS at import time. */
|
||
import {
|
||
withoutEmptyMembers,
|
||
isEmptyObject,
|
||
emptyObjectPaths,
|
||
limitViolations,
|
||
} from './policy-shapes.mjs';
|
||
|
||
const args = process.argv.slice(2);
|
||
const flag = (name) => {
|
||
const i = args.indexOf(`--${name}`);
|
||
return i === -1 ? undefined : args[i + 1];
|
||
};
|
||
const APPLY = args.includes('--apply');
|
||
|
||
/* ⚠️ AN UNRECOGNISED `--flag` IS A USAGE ERROR. `flag()` reads only the
|
||
`--name <value>` form, so `--function-arn=<arn>` is invisible to it and to
|
||
the presence check below: the run prints `no --function-arn given`, exits 0,
|
||
and attaches no router. `--dist=` and `--api-domain=` already failed safe on
|
||
the required-argument guard; `--function-arn=` was the one that degraded
|
||
quietly. This closes the `=` form and typos together. */
|
||
const KNOWN_FLAGS = new Set([
|
||
'--dist',
|
||
'--api-domain',
|
||
'--function-arn',
|
||
'--apply',
|
||
]);
|
||
const unknownFlags = args.filter(
|
||
(a) => a.startsWith('--') && !KNOWN_FLAGS.has(a),
|
||
);
|
||
if (unknownFlags.length) {
|
||
console.error(
|
||
`unrecognised argument(s): ${unknownFlags.join(', ')}\n` +
|
||
`Known flags: ${[...KNOWN_FLAGS].join(', ')}. A value is a separate ` +
|
||
`argument — write \`--function-arn <arn>\`, not \`--function-arn=<arn>\`.`,
|
||
);
|
||
process.exit(2);
|
||
}
|
||
|
||
const DIST = flag('dist');
|
||
const API_DOMAIN = flag('api-domain');
|
||
const FUNCTION_ARN = flag('function-arn');
|
||
|
||
if (!DIST || !API_DOMAIN) {
|
||
console.error(
|
||
'usage: node infra/cloudfront/configure.mjs --dist <distribution-id> ' +
|
||
'--api-domain <api-id>.execute-api.<region>.amazonaws.com ' +
|
||
'[--function-arn <router-function-arn>] [--apply]',
|
||
);
|
||
console.error('Values come from AGENTS.md §7.');
|
||
process.exit(2);
|
||
}
|
||
if (/^https?:/.test(API_DOMAIN) || API_DOMAIN.includes('/')) {
|
||
console.error(
|
||
`--api-domain must be a bare hostname, not a URL: got ${API_DOMAIN}`,
|
||
);
|
||
process.exit(2);
|
||
}
|
||
/* ⚠️ BEFORE ANY AWS CALL, because by the time the distribution payload exists
|
||
sections 4 and 5 may already have created policies. `--api-domain` was
|
||
guarded here and `--function-arn` was not, and the value that motivates this
|
||
is not hypothetical: see `policy-shapes.mjs`, PAYLOAD_LIMITS. */
|
||
/* ⚠️ PRESENCE, NOT TRUTHINESS. `docs/09` Part 2 says `$ROUTER_ARN` "is empty if
|
||
`publish-function` failed", so `''` is the documented failure of the step
|
||
that produces this argument — and falsy, so a truthiness test skips it here
|
||
and again in section 1, for a clean exit 0 that attaches no router. */
|
||
if (args.includes('--function-arn')) {
|
||
const bad = FUNCTION_ARN
|
||
? limitViolations('function-association', { FunctionARN: FUNCTION_ARN })
|
||
: [
|
||
{
|
||
message:
|
||
'--function-arn was given with an empty value. docs/09 Part 2: $ROUTER_ARN is empty if publish-function failed',
|
||
},
|
||
];
|
||
if (bad.length) {
|
||
console.error(
|
||
`--function-arn is not a CloudFront function ARN:\n` +
|
||
bad.map((b) => ` - ${b.message}`).join('\n') +
|
||
`\n got: ${JSON.stringify(FUNCTION_ARN)}\n` +
|
||
`Derive it with: aws cloudfront describe-function --name <name> --stage LIVE ` +
|
||
`--query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text\n` +
|
||
`NOT with list-functions, which returns one row per stage and joins them.`,
|
||
);
|
||
process.exit(2);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 🛑 **WHICH `aws` RAN IS PART OF THE RESULT, BECAUSE THIS SCRIPT DOES A FULL
|
||
* READ-MODIFY-WRITE.** `update-distribution` replaces the whole config, and
|
||
* botocore parses the config it reads against **its own** model, silently
|
||
* dropping members that model does not know. So an old CLI reads a lossy config
|
||
* and writes the loss back — on a distribution serving 23 public pages and the
|
||
* intake form. `--if-match` cannot catch it: the ETag is genuinely current.
|
||
*
|
||
* ⚠️ NOT HYPOTHETICAL ON THIS MACHINE. Measured 2026-09-04: two CLIs on PATH,
|
||
* `2.34.53` and `2.11.15` (April 2023). The older model does not know
|
||
* `GrpcConfig`, and `E1OK7G98KNKUTA` carries it on **two** behaviours. Both are
|
||
* `{Enabled: false}` today — the default — so the round trip happens to be
|
||
* lossless in effect, and **nothing would report it when that stops being true.**
|
||
* The old model is also missing `ConnectionMode`, `VpcOriginConfig`,
|
||
* `CacheTagConfig` and five more.
|
||
*
|
||
* The floor is the version this was verified against. Older fails loudly, which
|
||
* is the safe direction; newer passes. Lower it deliberately, with a Change Log
|
||
* entry, never to make a run go through.
|
||
*/
|
||
const AWS_CLI_FLOOR = [2, 34, 53];
|
||
const awsBinary = execFileSync('command', ['-v', 'aws'], {
|
||
encoding: 'utf8',
|
||
shell: '/bin/sh',
|
||
stdio: ['ignore', 'pipe', 'inherit'],
|
||
}).trim();
|
||
const awsVersionLine = execFileSync('aws', ['--version'], {
|
||
encoding: 'utf8',
|
||
stdio: ['ignore', 'pipe', 'inherit'],
|
||
}).trim();
|
||
const awsVersion = (awsVersionLine.match(/aws-cli\/(\d+)\.(\d+)\.(\d+)/) ?? [])
|
||
.slice(1)
|
||
.map(Number);
|
||
if (awsVersion.length !== 3) {
|
||
console.error(`could not read a version out of: ${awsVersionLine}`);
|
||
process.exit(2);
|
||
}
|
||
const belowFloor = (() => {
|
||
for (let i = 0; i < 3; i += 1) {
|
||
if (awsVersion[i] !== AWS_CLI_FLOOR[i])
|
||
return awsVersion[i] < AWS_CLI_FLOOR[i];
|
||
}
|
||
return false;
|
||
})();
|
||
console.log(
|
||
`resolved aws = ${awsBinary} (${awsVersion.join('.')})`,
|
||
);
|
||
if (belowFloor) {
|
||
console.error(
|
||
`\naws-cli ${awsVersion.join('.')} is below the floor ${AWS_CLI_FLOOR.join('.')}.\n` +
|
||
`This script rewrites the WHOLE distribution config, and botocore drops config ` +
|
||
`members its own model does not know — so an old CLI reads a lossy config and ` +
|
||
`writes the loss back. Measured 2026-09-04: 2.11.15 does not know GrpcConfig, ` +
|
||
`which this distribution carries on two behaviours.\n` +
|
||
`Resolved binary: ${awsBinary}. Check \`which -a aws\` — this machine has had two.`,
|
||
);
|
||
process.exit(2);
|
||
}
|
||
|
||
/* stderr is NEVER suppressed and the exit status is always read — the AWS CLI
|
||
reports an expired session, a missing permission and a typo'd id all on
|
||
stderr with a non-zero status, and swallowing that is how "it failed" becomes
|
||
"it found nothing" (CLAUDE.md, from AGENTS.md Q22). */
|
||
const aws = (argv) => {
|
||
const out = execFileSync('aws', argv, {
|
||
encoding: 'utf8',
|
||
stdio: ['ignore', 'pipe', 'inherit'],
|
||
maxBuffer: 64 * 1024 * 1024,
|
||
});
|
||
return out.trim() === '' ? null : JSON.parse(out);
|
||
};
|
||
|
||
const ORIGIN_ID = 'intake-api';
|
||
const PATH_PATTERN = '/api/*';
|
||
const ERROR_PAGE = '/404.html';
|
||
|
||
function managedId(kind, name) {
|
||
const listCmd = {
|
||
cache: ['list-cache-policies', 'CachePolicyList', 'CachePolicy'],
|
||
origreq: [
|
||
'list-origin-request-policies',
|
||
'OriginRequestPolicyList',
|
||
'OriginRequestPolicy',
|
||
],
|
||
}[kind];
|
||
const res = aws([
|
||
'cloudfront',
|
||
listCmd[0],
|
||
'--type',
|
||
'managed',
|
||
'--output',
|
||
'json',
|
||
]);
|
||
const items = res?.[listCmd[1]]?.Items ?? [];
|
||
const hit = items.find(
|
||
(i) => i[listCmd[2]][`${listCmd[2]}Config`].Name === name,
|
||
);
|
||
if (!hit) {
|
||
throw new Error(
|
||
`no managed ${kind} policy named ${name} — ${items.length} listed. ` +
|
||
'Do not substitute an id from memory.',
|
||
);
|
||
}
|
||
return hit[listCmd[2]].Id;
|
||
}
|
||
|
||
const cachingDisabled = managedId('cache', 'Managed-CachingDisabled');
|
||
/* AllViewerExceptHostHeader, and the exception is the whole reason: API Gateway
|
||
routes on the Host header, so forwarding the viewer's `adr.smlcompany.ca`
|
||
makes every request a 403 from the API. It forwards everything else, which is
|
||
what carries `Origin` and `Referer` — the handler's CSRF control reads both,
|
||
so a policy that dropped them would turn every real submission into a 403. */
|
||
const allViewerExceptHost = managedId(
|
||
'origreq',
|
||
'Managed-AllViewerExceptHostHeader',
|
||
);
|
||
console.log(`resolved Managed-CachingDisabled = ${cachingDisabled}`);
|
||
console.log(
|
||
`resolved Managed-AllViewerExceptHostHeader = ${allViewerExceptHost}`,
|
||
);
|
||
|
||
const current = aws([
|
||
'cloudfront',
|
||
'get-distribution-config',
|
||
'--id',
|
||
DIST,
|
||
'--output',
|
||
'json',
|
||
]);
|
||
const etag = current.ETag;
|
||
const cfg = current.DistributionConfig;
|
||
if (!etag || !cfg) throw new Error('could not read the distribution config');
|
||
|
||
const changes = [];
|
||
|
||
/* ---- 1. viewer-request function on the default behaviour ---------------- */
|
||
if (FUNCTION_ARN) {
|
||
const fa = cfg.DefaultCacheBehavior.FunctionAssociations ?? { Quantity: 0 };
|
||
const existing = (fa.Items ?? []).filter(
|
||
(i) => i.EventType === 'viewer-request',
|
||
);
|
||
if (existing.length === 1 && existing[0].FunctionARN === FUNCTION_ARN) {
|
||
console.log(
|
||
'· default behaviour already runs this function on viewer-request',
|
||
);
|
||
} else {
|
||
const items = (fa.Items ?? []).filter(
|
||
(i) => i.EventType !== 'viewer-request',
|
||
);
|
||
items.push({ EventType: 'viewer-request', FunctionARN: FUNCTION_ARN });
|
||
cfg.DefaultCacheBehavior.FunctionAssociations = {
|
||
Quantity: items.length,
|
||
Items: items,
|
||
};
|
||
changes.push(
|
||
`DefaultCacheBehavior.FunctionAssociations viewer-request -> ${FUNCTION_ARN}` +
|
||
(existing.length ? ` (replacing ${existing[0].FunctionARN})` : ''),
|
||
);
|
||
}
|
||
} else {
|
||
console.log('· no --function-arn given, leaving FunctionAssociations alone');
|
||
}
|
||
|
||
/* ---- 2. custom error response ------------------------------------------- */
|
||
/* ⚠️ ONLY 404 IS MAPPED, NOT 403, AND THAT IS DELIBERATE. Mapping 403 as well
|
||
would swallow two different real failures: a broken bucket policy or OAC
|
||
would render as "page not found" on every URL at once, and the intake
|
||
handler's Origin refusal (a 403 from the API origin) would come back as a 404
|
||
page. Custom error responses are distribution-wide — they cannot be scoped to
|
||
one behaviour — so the fix for missing keys is on the S3 side instead:
|
||
granting the OAC principal `s3:ListBucket` makes S3 answer 404 NoSuchKey
|
||
rather than 403 AccessDenied. Runbook Part 1 does that first, and its
|
||
verification step is what proves this mapping is reached. */
|
||
const cer = cfg.CustomErrorResponses ?? { Quantity: 0, Items: [] };
|
||
const cerItems = cer.Items ?? [];
|
||
const has404 = cerItems.some(
|
||
(i) =>
|
||
i.ErrorCode === 404 &&
|
||
i.ResponsePagePath === ERROR_PAGE &&
|
||
String(i.ResponseCode) === '404',
|
||
);
|
||
if (has404) {
|
||
console.log('· 404 -> /404.html (404) already configured');
|
||
} else {
|
||
/* Report a REPLACEMENT as a replacement. This branch filters out any existing
|
||
404 mapping, so on a distribution that maps 404 to a different page the
|
||
operator would otherwise be told a mapping was "added" while one was
|
||
silently changed — and Part 3 tells them to carry on when the change count is
|
||
lower than expected. The function-association branch above already names
|
||
what it replaces; this one did not. */
|
||
const replaced = cerItems.find((i) => i.ErrorCode === 404);
|
||
const items = cerItems.filter((i) => i.ErrorCode !== 404);
|
||
items.push({
|
||
ErrorCode: 404,
|
||
ResponsePagePath: ERROR_PAGE,
|
||
ResponseCode: '404',
|
||
/* Short, not zero. A 404 is cheap to re-fetch and this is the value that
|
||
decides how long a genuinely-missing URL keeps 404ing after the page it
|
||
should have been is deployed. */
|
||
ErrorCachingMinTTL: 10,
|
||
});
|
||
cfg.CustomErrorResponses = { Quantity: items.length, Items: items };
|
||
changes.push(
|
||
replaced
|
||
? `CustomErrorResponses 404 -> ${ERROR_PAGE} with status 404 (REPLACING ` +
|
||
`${replaced.ResponsePagePath} with status ${replaced.ResponseCode})`
|
||
: `CustomErrorResponses += 404 -> ${ERROR_PAGE} with status 404`,
|
||
);
|
||
}
|
||
|
||
/* ---- 3. the /api/* origin and behaviour --------------------------------- */
|
||
const origins = cfg.Origins.Items ?? [];
|
||
if (origins.some((o) => o.Id === ORIGIN_ID)) {
|
||
console.log(`· origin ${ORIGIN_ID} already present`);
|
||
} else {
|
||
origins.push({
|
||
Id: ORIGIN_ID,
|
||
DomainName: API_DOMAIN,
|
||
OriginPath: '',
|
||
CustomHeaders: { Quantity: 0 },
|
||
CustomOriginConfig: {
|
||
HTTPPort: 80,
|
||
HTTPSPort: 443,
|
||
/* https-only to the origin. The API is public over TLS and there is no
|
||
reason for a leg of this in plaintext. */
|
||
OriginProtocolPolicy: 'https-only',
|
||
OriginSslProtocols: { Quantity: 1, Items: ['TLSv1.2'] },
|
||
OriginReadTimeout: 30,
|
||
OriginKeepaliveTimeout: 5,
|
||
},
|
||
ConnectionAttempts: 3,
|
||
ConnectionTimeout: 10,
|
||
OriginShield: { Enabled: false },
|
||
});
|
||
cfg.Origins = { Quantity: origins.length, Items: origins };
|
||
changes.push(
|
||
`Origins += ${ORIGIN_ID} -> ${API_DOMAIN} (https-only, TLSv1.2)`,
|
||
);
|
||
}
|
||
|
||
const behaviours = cfg.CacheBehaviors?.Items ?? [];
|
||
if (behaviours.some((b) => b.PathPattern === PATH_PATTERN)) {
|
||
console.log(`· cache behaviour ${PATH_PATTERN} already present`);
|
||
} else {
|
||
behaviours.push({
|
||
PathPattern: PATH_PATTERN,
|
||
TargetOriginId: ORIGIN_ID,
|
||
ViewerProtocolPolicy: 'https-only',
|
||
/* POST is the one that matters; the rest are here because CloudFront only
|
||
offers the three fixed method sets and this is the set containing POST. */
|
||
AllowedMethods: {
|
||
Quantity: 7,
|
||
Items: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'],
|
||
CachedMethods: { Quantity: 2, Items: ['GET', 'HEAD'] },
|
||
},
|
||
CachePolicyId: cachingDisabled,
|
||
OriginRequestPolicyId: allViewerExceptHost,
|
||
Compress: false,
|
||
SmoothStreaming: false,
|
||
FieldLevelEncryptionId: '',
|
||
/* NO FUNCTION ASSOCIATION, AND THE OMISSION IS LOAD-BEARING. `router.js`
|
||
would 301 `/api/intake` to `/api/intake/`, and a 301 turns a POST into a
|
||
GET — the submission body would be dropped with a 200 at the end of it.
|
||
`infra/cloudfront/router.test.mjs` carries that case as documentation. */
|
||
FunctionAssociations: { Quantity: 0 },
|
||
LambdaFunctionAssociations: { Quantity: 0 },
|
||
TrustedKeyGroups: { Enabled: false, Quantity: 0 },
|
||
});
|
||
cfg.CacheBehaviors = { Quantity: behaviours.length, Items: behaviours };
|
||
changes.push(
|
||
`CacheBehaviors += ${PATH_PATTERN} -> ${ORIGIN_ID}, CachingDisabled, AllViewerExceptHostHeader, POST allowed`,
|
||
);
|
||
}
|
||
|
||
/* CloudFront matches cache behaviours in order and the FIRST match wins, so a
|
||
`/api/*` behaviour placed after a hypothetical `/*` one would never be
|
||
reached. There is no `/*` behaviour today — the default behaviour is the
|
||
catch-all and is not part of this list — but assert it rather than assume it. */
|
||
const catchAll = (cfg.CacheBehaviors?.Items ?? []).findIndex(
|
||
(b) => b.PathPattern === '*' || b.PathPattern === '/*',
|
||
);
|
||
const apiIndex = (cfg.CacheBehaviors?.Items ?? []).findIndex(
|
||
(b) => b.PathPattern === PATH_PATTERN,
|
||
);
|
||
if (catchAll !== -1 && catchAll < apiIndex) {
|
||
throw new Error(
|
||
`a catch-all behaviour at index ${catchAll} precedes ${PATH_PATTERN} at ${apiIndex} — ` +
|
||
'the API behaviour would never match. Reorder before applying.',
|
||
);
|
||
}
|
||
|
||
/* ---- 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';
|
||
/* ⚠️ 128 CHARACTERS, SERVER-SIDE — nothing local checks it. Keep it short and
|
||
put the explanation in section 4.
|
||
|
||
⚠️ `Comment` IS DELIBERATELY NOT IN THE DRIFT CHECK. The live policy carries
|
||
the original, longer text (§7); reconciling it would throw on that policy and
|
||
block the run that attaches it. */
|
||
const PDF_POLICY_COMMENT =
|
||
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.';
|
||
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 = [];
|
||
/* ⚠️ PARKED, NOT SKIPPED, AND THE DISTINCTION IS THE EXIT STATUS. A skip means
|
||
something unexpected happened and someone should look; these two are a
|
||
standing platform constraint that will be true on every run until the pricing
|
||
plan changes. Counting them as skips would make `exit 3` permanent, and a
|
||
signal that is always on is not a signal. */
|
||
const parked = [];
|
||
|
||
/**
|
||
* 🛑 **SECTIONS 4 AND 5 CANNOT BE APPLIED ON THIS DISTRIBUTION'S PRICING PLAN.**
|
||
* The third `--apply` of 2026-09-04 reached `update-distribution` and was
|
||
* rejected atomically:
|
||
*
|
||
* Distributions with the Free pricing plan can't have the following features:
|
||
* Custom origin request policy, Custom response headers policy
|
||
*
|
||
* ⚠️ **AND IT FAILED AT THE LAST CALL, AFTER BOTH POLICIES HAD BEEN CREATED** —
|
||
* the whole point of the pre-flight is to fail before that, so both sections now
|
||
* stop here instead. `AGENTS.md` §7 records the plan; `docs/06` closes both
|
||
* items; `docs/09` Part 3 has all three attempts.
|
||
*
|
||
* ⚠️ **THIS IS A CONSTANT AND NOT A PROBE, BECAUSE THE PRICING PLAN IS NOT IN
|
||
* THE API.** Checked 2026-09-04 against the CloudFront model: 167 operations,
|
||
* and not one shape, member or documentation string mentions a pricing plan.
|
||
* `PriceClass_All` on this distribution is the EDGE-LOCATION price class, a
|
||
* different and much older concept — do not read it as the plan. The only
|
||
* signal AWS gives is the `update-distribution` rejection itself, which is the
|
||
* thing this exists to avoid. So: flip this to `true` when the plan changes,
|
||
* and the two sections come back exactly as they were.
|
||
*/
|
||
const PLAN_ALLOWS_CUSTOM_POLICIES = false;
|
||
|
||
if (!PLAN_ALLOWS_CUSTOM_POLICIES) {
|
||
parked.push(
|
||
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — a custom response headers policy is not available on this distribution's pricing plan. The stand-in is \`Disallow: /pouya-lajevardi-bio.pdf\` in public/robots.txt`,
|
||
);
|
||
} else 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;
|
||
const clonedShc = withoutEmptyMembers(srcCfg?.SecurityHeadersConfig);
|
||
const undefinedMembers = Object.keys(
|
||
srcCfg?.SecurityHeadersConfig ?? {},
|
||
).filter((k) => !(k in clonedShc));
|
||
/* ⚠️ 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`.
|
||
|
||
⚠️ TWO CONDITIONS BELOW STILL ABORT THE WHOLE RUN, AND THEY ARE THE ONLY
|
||
TWO: the DRIFT throw, and the throw on a `*.pdf` behaviour this script
|
||
cannot account for. Both are divergences rather than absences — someone
|
||
else has configured this distribution — and `docs/09` Part 3 argues for
|
||
stopping on them. Everything else section 4 can hit, including an empty
|
||
object in the generated config and a breached CloudFront limit, is a SKIP.
|
||
(This comment said "the EMPTY-OBJECT throw" after that throw had been
|
||
converted to a skip. Which conditions abort is this file's most
|
||
safety-critical property; count them in the code, not here.) */
|
||
if (!srcCfg?.SecurityHeadersConfig) {
|
||
skipped.push(
|
||
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`,
|
||
);
|
||
} else if (Object.keys(clonedShc).length === 0) {
|
||
/* Distinct from the branch above, and the distinction is the operator's:
|
||
ABSENT means the source policy is a different kind of thing, while
|
||
ALL-EMPTY means it is the right kind and defines nothing. Both leave the
|
||
PDF without security headers; only the second would read as a bug. */
|
||
skipped.push(
|
||
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has a SecurityHeadersConfig that defines nothing` +
|
||
(undefinedMembers.length
|
||
? ` (all ${undefinedMembers.length} of its members are empty: ${undefinedMembers.join(', ')})`
|
||
: ' — it is an empty object') +
|
||
`, so there are no security headers to clone`,
|
||
);
|
||
} else {
|
||
/* ⚠️ UNCONDITIONAL. This was gated on `undefinedMembers.length`, so on a
|
||
source that defines all six the line DISAPPEARED rather than reading six
|
||
— and `docs/09` Part 3 tells the operator to read this number as the
|
||
count of headers the PDF will carry. An absent line is not a smaller
|
||
number; it is nothing to compare (`adversarial-reviewer`). */
|
||
console.log(
|
||
`· cloning ${Object.keys(clonedShc).length} defined security header(s)` +
|
||
(undefinedMembers.length
|
||
? `; omitting ${undefinedMembers.length} the source does not define (${undefinedMembers.join(', ')})`
|
||
: ''),
|
||
);
|
||
const wanted = withoutEmptyMembers({
|
||
SecurityHeadersConfig: clonedShc,
|
||
...(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],
|
||
},
|
||
});
|
||
|
||
/* ⚠️ ASSERT BEFORE ANY BRANCH, SO THE DRY RUN CARRIES IT TOO — Pouya's
|
||
ruling of 2026-09-04. The clone that failed was syntactically fine and
|
||
printed a clean dry run; a check that runs only on the writing path
|
||
reports the class after it has already cost the run.
|
||
|
||
⚠️ A SKIP, NOT A THROW — it was a throw for one round. An empty object
|
||
here is section 4's problem alone, and this file's contract is that
|
||
section 4 never blocks sections 1-3 from re-applying `router.js`, without
|
||
which 22 of 23 pages return S3's AccessDenied. A skip is already loud: it
|
||
prints under its own heading and the NOTHING TO CHANGE guard names it. */
|
||
/* Built once and checked, then sent — not rebuilt at the call. That is
|
||
what makes the dry run a pre-flight. Checked only when a create would
|
||
happen; a breach on a payload nobody sends is a false alarm. */
|
||
const pdfPolicyConfig = {
|
||
Name: PDF_POLICY_NAME,
|
||
Comment: PDF_POLICY_COMMENT,
|
||
...wanted,
|
||
};
|
||
const pdfBreaches = existingPdfPolicy
|
||
? []
|
||
: limitViolations('response-headers-policy', pdfPolicyConfig);
|
||
const empties = emptyObjectPaths(wanted);
|
||
/* ⚠️ ONE GUARD FOR THE WHOLE SECTION, NOT A BRANCH ROUND THE CREATE. No
|
||
policy means `pdfPolicyId` stays null, and the behaviour below would then
|
||
be staged carrying the placeholder string as its
|
||
ResponseHeadersPolicyId. */
|
||
const sectionFourBlockers = [
|
||
...(empties.length
|
||
? [
|
||
`the generated policy config still contains ${empties.length} empty object(s) ` +
|
||
`(${empties.join(', ')}), which AWS rejects on ParamValidation before the call ` +
|
||
`leaves the machine. withoutEmptyMembers should have removed them, so either it ` +
|
||
`is broken or this script built one itself — see policy-shapes.mjs and its test`,
|
||
]
|
||
: []),
|
||
...pdfBreaches.map(
|
||
(b) =>
|
||
`the policy config would breach a CloudFront limit: ${b.message}`,
|
||
),
|
||
];
|
||
if (sectionFourBlockers.length) {
|
||
for (const blocker of sectionFourBlockers) {
|
||
skipped.push(`${PDF_PATTERN} / ${PDF_POLICY_NAME} — ${blocker}`);
|
||
}
|
||
} else {
|
||
if (existingPdfPolicy) {
|
||
const have = existingPdfPolicy.ResponseHeadersPolicyConfig;
|
||
/* ⚠️ `{}` AND ABSENT MUST NORMALISE ALIKE, ON BOTH SIDES. `wanted` has
|
||
had AWS's placeholders stripped; the live policy may still echo them.
|
||
Stripping alone is not enough — that leaves `norm({})` as `"{}"`
|
||
against `norm(undefined)` as `"null"`, which reports drift that no
|
||
`update-response-headers-policy` can clear, on the intake form's own
|
||
path. An empty member is never a real divergence. */
|
||
const norm = (o) => {
|
||
const v = withoutEmptyMembers(o);
|
||
return JSON.stringify(isEmptyObject(v) ? null : (v ?? 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 wanted : ${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}` +
|
||
` (payload within every limit in PAYLOAD_LIMITS)`,
|
||
);
|
||
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',
|
||
/* The object `limitViolations` inspected, not a second literal built
|
||
here. Section 5 already did this; two literals that agree today are
|
||
how a pre-flight stops covering the payload. */
|
||
JSON.stringify(pdfPolicyConfig),
|
||
'--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)'),
|
||
);
|
||
/* ⚠️ `!APPLY` ALONE, never gated on the policy id: knowing the id is a
|
||
reason to show the payload, not to hide it. This is the only live
|
||
change section 4 makes. */
|
||
if (!APPLY) {
|
||
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,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---- 5. CloudFront-Viewer-Address on /api/* -----------------------------
|
||
⚠️ THIS IS THE ONE CHANGE IN THIS FILE THAT CAN BREAK A LIVE FORM, AND THE
|
||
VERIFY-AND-ROLLBACK BLOCK IN `docs/09` PART 3 IS NOT OPTIONAL AFTER IT.
|
||
Everything else here ADDS. This one REPLACES the origin request policy on the
|
||
behaviour that carries real legal inquiries: get the header set wrong and
|
||
every submission redirects to /contact/could-not-send/, which looks like a
|
||
browser problem and is not.
|
||
|
||
WHY A WHITELIST, WHICH IS NOT THE OBVIOUS CHOICE. The wanted forwarding is
|
||
"every viewer header except Host, plus CloudFront-Viewer-Address", and NO
|
||
ORIGIN REQUEST POLICY EXPRESSES IT. ⚠️ THAT IS A CLAIM ABOUT ORIGIN REQUEST
|
||
POLICIES, NOT ABOUT AWS, AND IT SAID "AWS has no behaviour that expresses it"
|
||
FOR ONE ROUND — the shape `CLAUDE.md` names: "no mechanism can X" is a claim
|
||
about every mechanism, including the ones you did not enumerate. **The one
|
||
not enumerated: a viewer-request CloudFront Function on /api/* that copies
|
||
`event.viewer.ip` into a custom header, leaving the managed policy in place.**
|
||
That removes this section's entire failure class — nothing can be dropped
|
||
because nothing is re-listed — at the cost of a second function on a path
|
||
whose "no function association" comment is load-bearing for a different
|
||
reason (a 301 would turn the POST into a GET; a header-only function would
|
||
not). It is not built because Pouya's ruling names an origin request policy;
|
||
it is written down so the choice is visible rather than implied.
|
||
|
||
Derived from the API's own enum, not recalled:
|
||
|
||
allViewer - viewer headers only, Host included
|
||
allExcept - viewer headers minus a list; the list is
|
||
an EXCLUSION, so nothing can be added
|
||
allViewerAndWhitelistCloudFront - viewer headers PLUS CloudFront headers,
|
||
and "viewer headers" includes Host, which
|
||
403s at API Gateway. `docs/09` warns
|
||
against exactly this one
|
||
whitelist - only the listed headers, and CloudFront
|
||
headers may be listed
|
||
|
||
CloudFront-generated headers exist in none of the "allViewer*" sets except
|
||
the one that also drags Host along. So `whitelist` is the only shape left,
|
||
and the cost of it is that the list below is now load-bearing: a header
|
||
omitted here is a header the handler never sees.
|
||
|
||
⚠️ THE LIST IS THE HANDLER'S OWN READS, AND NOTHING ELSE. `handler.mjs` reads
|
||
exactly four headers — content-type, origin, referer, user-agent. Adding a
|
||
fifth read there without adding it here is silent: the value simply arrives
|
||
undefined. The check PRINTS THE NAMES, so it can be compared to the list
|
||
above rather than counted:
|
||
|
||
grep -o "headerOf(event, '[a-z-]*'" backend/intake/handler.mjs \\
|
||
| sed "s/.*'\\(.*\\)'/\\1/" | sort
|
||
|
||
⚠️ `grep -n "headerOf(event"` WAS PRESCRIBED HERE AND IN TWO DOCUMENTS AND IT
|
||
RETURNS FIVE, NOT FOUR — it matches `function headerOf(event, name)`, its own
|
||
definition. An operator comparing 5 against a documented 4 concludes the
|
||
handler grew a read it did not grow. A count is the wrong instrument when the
|
||
names are what the whitelist has to match.
|
||
|
||
⚠️ COOKIES AND QUERY STRINGS STAY `all`, MATCHING THE MANAGED POLICY THIS
|
||
REPLACES. The site sets no cookies and the endpoint reads no query string, so
|
||
`none` would be tidier and is deliberately not used: the only reviewable
|
||
delta should be the header set. A second change hidden inside this one is how
|
||
a rollback stops being a rollback.
|
||
|
||
WHAT IT BUYS, AND IT IS NOT USED YET. `requestContext.http.sourceIp` behind
|
||
this behaviour is a CloudFront edge, so the stored value identifies AWS
|
||
rather than the sender, and `x-forwarded-for` is client-forgeable — see
|
||
`viewerIp()`. `CloudFront-Viewer-Address` is generated and overwritten by
|
||
CloudFront, so it is the one trustworthy value. Pouya's ruling of 2026-09-04:
|
||
forward it so per-IP measures become possible later, MEASURED AND NOT YET
|
||
ACTED ON. The handler is unchanged and still stores the edge address.
|
||
|
||
⚠️ SO DO NOT "FIX" `viewerIp()` TO READ THIS HEADER AS A FOLLOW-UP. What the
|
||
record holds is published on `/legal/privacy/`, field by field; changing the
|
||
stored value changes a disclosure, and `docs/09` §7.2's decision table is the
|
||
procedure for that. Forwarding a header is infrastructure. Storing it is a
|
||
privacy-policy edit. */
|
||
const ORP_NAME = 'adr-sml-api-viewer-address';
|
||
/* ⚠️ THIS WAS 182 CHARACTERS AND IT FAILED THE SECOND `--apply`, 2026-09-04:
|
||
InvalidArgument, "The parameter Comment is too big". The cap is 128 and it is
|
||
server-side — the model types `Comment` as a bare `string`, so nothing local
|
||
saw it. Section 4 had already created its policy by then, so the run left an
|
||
orphan. `docs/09` Part 3 carries both attempts. */
|
||
const ORP_COMMENT =
|
||
'Forwards CloudFront-Viewer-Address on /api/*. See configure.mjs section 5.';
|
||
/* Sorted, because the drift check below compares this list to what CloudFront
|
||
returns and an ordering difference would read as a drift. */
|
||
const ORP_HEADERS = [
|
||
'CloudFront-Viewer-Address',
|
||
'Content-Type',
|
||
'Origin',
|
||
'Referer',
|
||
'User-Agent',
|
||
];
|
||
|
||
/**
|
||
* ⚠️ THE WHITELIST IS CHECKED AGAINST THE HANDLER'S SOURCE, NOT AGAINST A
|
||
* COMMENT. `ORP_HEADERS` is a second copy of a fact `backend/intake/handler.mjs`
|
||
* owns, and this repository's rule is that a duplicated fact needs a mechanism —
|
||
* `npm run check:intake` exists for exactly this shape. Until 2026-09-04 the
|
||
* only thing keeping the two in step was a comment plus a grep an operator was
|
||
* asked to run by eye, on the one change that can break a live intake form.
|
||
*
|
||
* A header the handler reads and this list omits is silently `undefined` at run
|
||
* time. So: read the handler, extract every `headerOf(event, '<name>')`, and
|
||
* refuse to proceed if any of them is missing here. **Missing FILE is a skip,
|
||
* not a throw** — `configure.mjs` must stay runnable from a checkout that does
|
||
* not carry `backend/`, and sections 1-3 have already staged their work.
|
||
*/
|
||
function handlerHeaderReads() {
|
||
const path = new URL('../../backend/intake/handler.mjs', import.meta.url)
|
||
.pathname;
|
||
let src;
|
||
try {
|
||
src = readFileSync(path, 'utf8');
|
||
} catch {
|
||
return null;
|
||
}
|
||
return [
|
||
...new Set(
|
||
[...src.matchAll(/headerOf\(event,\s*'([a-z-]+)'/g)].map((m) => m[1]),
|
||
),
|
||
].sort();
|
||
}
|
||
|
||
function findApiOriginRequestPolicy() {
|
||
const res = aws([
|
||
'cloudfront',
|
||
'list-origin-request-policies',
|
||
'--type',
|
||
'custom',
|
||
'--output',
|
||
'json',
|
||
]);
|
||
const items = res?.OriginRequestPolicyList?.Items ?? [];
|
||
return (
|
||
items.find(
|
||
(i) => i.OriginRequestPolicy.OriginRequestPolicyConfig.Name === ORP_NAME,
|
||
)?.OriginRequestPolicy ?? null
|
||
);
|
||
}
|
||
|
||
/* Read from `cfg`, not from `behaviours`: section 3 may have just staged this
|
||
behaviour in the same run, and it must be reachable either way. */
|
||
const apiBehaviour = (cfg.CacheBehaviors?.Items ?? []).find(
|
||
(b) => b.PathPattern === PATH_PATTERN,
|
||
);
|
||
|
||
if (!PLAN_ALLOWS_CUSTOM_POLICIES) {
|
||
parked.push(
|
||
`${PATH_PATTERN} / ${ORP_NAME} — a custom origin request policy is not available on this distribution's pricing plan. Superseded rather than merely parked: the WAF web ACL already attached to this distribution is where a per-IP rule belongs (AGENTS.md §7)`,
|
||
);
|
||
} else if (!apiBehaviour) {
|
||
/* Unreachable in practice — section 3 either found it or pushed it — so if it
|
||
fires, something above changed. Skip rather than throw, for the reason
|
||
section 4 gives: sections 1-3 have already staged their mutations. */
|
||
skipped.push(
|
||
`${PATH_PATTERN} / ${ORP_NAME} — no ${PATH_PATTERN} cache behaviour to attach it to`,
|
||
);
|
||
} else {
|
||
const reads = handlerHeaderReads();
|
||
/* ⚠️ THE PROBE CAN BREAK, AND A BROKEN PROBE READS AS A CLEAN PASS. `reads`
|
||
is `null` when the file is absent and `[]` when the regex stops matching —
|
||
rename `headerOf`, switch to double quotes, pass the name as a constant,
|
||
and `missing` is empty, nothing throws, and the run prints
|
||
`· whitelist covers all 0 headers the handler reads ()` before replacing the
|
||
policy on the intake form's path. CLAUDE.md: re-check "uniformly GOOD" too.
|
||
The handler has four reads today.
|
||
|
||
⚠️ EITHER CASE SKIPS THE WHOLE SECTION — recording a skip and continuing
|
||
would replace the policy on the live intake path with a whitelist nothing
|
||
verified, under a heading saying nothing changed. */
|
||
const readsBlocker =
|
||
reads === null
|
||
? "backend/intake/handler.mjs is not in this checkout, so the whitelist could not be checked against the handler's own reads"
|
||
: reads.length === 0
|
||
? "the handler-reads probe matched nothing. It greps for headerOf(event, '<name>') in backend/intake/handler.mjs, which has four reads today, so zero means the probe is broken rather than that the handler reads nothing"
|
||
: null;
|
||
if (readsBlocker) {
|
||
skipped.push(`${PATH_PATTERN} / ${ORP_NAME} — ${readsBlocker}`);
|
||
} else {
|
||
const lower = ORP_HEADERS.map((h) => h.toLowerCase());
|
||
const missing = reads.filter((h) => !lower.includes(h));
|
||
if (missing.length) {
|
||
throw new Error(
|
||
`${ORP_NAME} would NOT forward ${missing.length} header(s) the handler reads: ` +
|
||
`${missing.join(', ')}.\n` +
|
||
` handler reads : ${reads.join(', ')}\n` +
|
||
` whitelist : ${lower.join(', ')}\n` +
|
||
`Every submission would validate short and land on /contact/could-not-send/, ` +
|
||
`which reads to the inquirer as their own browser. Add the header to ORP_HEADERS ` +
|
||
`and re-run. This is checked here rather than by eye because the grep that was ` +
|
||
`prescribed for it returned five lines for four reads.`,
|
||
);
|
||
}
|
||
console.log(
|
||
`· whitelist covers all ${reads.length} headers the handler reads (${reads.join(', ')})`,
|
||
);
|
||
|
||
const existingOrp = findApiOriginRequestPolicy();
|
||
let orpId = existingOrp?.Id ?? null;
|
||
|
||
const wantedOrp = {
|
||
HeadersConfig: {
|
||
HeaderBehavior: 'whitelist',
|
||
Headers: { Quantity: ORP_HEADERS.length, Items: ORP_HEADERS },
|
||
},
|
||
CookiesConfig: { CookieBehavior: 'all' },
|
||
QueryStringsConfig: { QueryStringBehavior: 'all' },
|
||
};
|
||
|
||
/* Built once and checked, for the reason section 4 gives — and this is the
|
||
payload that actually failed. */
|
||
const orpPolicyConfig = {
|
||
Name: ORP_NAME,
|
||
Comment: ORP_COMMENT,
|
||
...wantedOrp,
|
||
};
|
||
const orpBreaches = existingOrp
|
||
? []
|
||
: limitViolations('origin-request-policy', orpPolicyConfig);
|
||
|
||
if (existingOrp) {
|
||
const have = existingOrp.OriginRequestPolicyConfig;
|
||
const norm = (o) => JSON.stringify(o ?? null);
|
||
/* Compare the header ITEMS as a sorted set rather than the whole
|
||
HeadersConfig object: CloudFront echoes `Quantity` back and a list that
|
||
differs only in order is the same forwarding rule. A drift report that
|
||
fires on ordering is a drift report nobody reads twice. */
|
||
const haveHeaders = [
|
||
...(have.HeadersConfig?.Headers?.Items ?? []),
|
||
].sort();
|
||
const drift = [];
|
||
if (have.HeadersConfig?.HeaderBehavior !== 'whitelist')
|
||
drift.push([
|
||
'HeaderBehavior',
|
||
have.HeadersConfig?.HeaderBehavior,
|
||
'whitelist',
|
||
]);
|
||
if (norm(haveHeaders) !== norm([...ORP_HEADERS].sort()))
|
||
drift.push(['Headers', norm(haveHeaders), norm(ORP_HEADERS)]);
|
||
for (const k of ['CookiesConfig', 'QueryStringsConfig']) {
|
||
if (norm(have[k]) !== norm(wantedOrp[k]))
|
||
drift.push([k, norm(have[k]), norm(wantedOrp[k])]);
|
||
}
|
||
if (drift.length) {
|
||
/* Both sides, same rule as section 4: naming the field does not say which
|
||
direction to repair in, and here the two directions are "the handler
|
||
reads a header nobody forwards" and "CloudFront forwards a header
|
||
nobody reads". Only one of those loses inquiries. */
|
||
throw new Error(
|
||
`${ORP_NAME} has DRIFTED from what this script expects on ` +
|
||
`${drift.length} field(s). ${PATH_PATTERN} is the intake form's path, ` +
|
||
`so read which way before repairing:\n` +
|
||
drift
|
||
.map(
|
||
([k, a, b]) => ` ${k}\n live : ${a}\n wanted : ${b}`,
|
||
)
|
||
.join('\n') +
|
||
`\nReconcile with update-origin-request-policy (it needs the policy's ` +
|
||
`own ETag), then re-run.` +
|
||
`\n\nNOTE: in an --apply run this throws AFTER section 4 may already have ` +
|
||
`created ${PDF_POLICY_NAME}, and BEFORE update-distribution is called — ` +
|
||
`so a policy can exist that no behaviour references. That is harmless ` +
|
||
`and self-healing: the next run finds it by name, matches it, and ` +
|
||
`attaches it. Do not delete it by hand.`,
|
||
);
|
||
}
|
||
console.log(`· origin request policy ${ORP_NAME} exists and matches`);
|
||
} else if (orpBreaches.length) {
|
||
skipped.push(
|
||
`${PATH_PATTERN} / ${ORP_NAME} — the policy config this script would send breaches ` +
|
||
`${orpBreaches.length} CloudFront limit(s): ${orpBreaches.map((b) => b.message).join('; ')}`,
|
||
);
|
||
} else if (!APPLY) {
|
||
console.log(
|
||
`· would CREATE origin request policy ${ORP_NAME}` +
|
||
` (payload within every limit in PAYLOAD_LIMITS)`,
|
||
);
|
||
changes.push(
|
||
`create origin request policy ${ORP_NAME} (whitelist: ${ORP_HEADERS.join(', ')}; cookies all; query strings all)`,
|
||
);
|
||
} else {
|
||
const created = aws([
|
||
'cloudfront',
|
||
'create-origin-request-policy',
|
||
'--origin-request-policy-config',
|
||
JSON.stringify(orpPolicyConfig),
|
||
'--output',
|
||
'json',
|
||
]);
|
||
orpId = created?.OriginRequestPolicy?.Id;
|
||
if (!orpId) {
|
||
throw new Error(
|
||
`create-origin-request-policy returned no Id for ${ORP_NAME}`,
|
||
);
|
||
}
|
||
changes.push(`created origin request policy ${ORP_NAME} (${orpId})`);
|
||
}
|
||
|
||
/* 🛑 NO POLICY, NO ATTACHMENT. If the create above was skipped, `orpId` is
|
||
null — and the `--apply` branch below assigns it unconditionally, so this
|
||
would set the intake form's own behaviour to a null OriginRequestPolicyId.
|
||
That REMOVES header forwarding from `/api/*`: `Origin`, `Referer` and
|
||
`Content-Type` stop reaching the handler and every submission fails while
|
||
looking like the visitor's browser. The dry run staged the line too, which
|
||
is how this was found. */
|
||
if (orpBreaches.length || (APPLY && !orpId)) {
|
||
console.log(
|
||
`· NOT touching ${PATH_PATTERN}'s OriginRequestPolicyId — ${ORP_NAME} was not created`,
|
||
);
|
||
} else if (orpId && apiBehaviour.OriginRequestPolicyId === orpId) {
|
||
console.log(`· ${PATH_PATTERN} already uses ${ORP_NAME}`);
|
||
} else if (!APPLY) {
|
||
console.log(
|
||
`· would SET ${PATH_PATTERN} OriginRequestPolicyId -> ${ORP_NAME}` +
|
||
` (from ${apiBehaviour.OriginRequestPolicyId})`,
|
||
);
|
||
changes.push(
|
||
`${PATH_PATTERN} OriginRequestPolicyId ${apiBehaviour.OriginRequestPolicyId} -> ${ORP_NAME}`,
|
||
);
|
||
} else {
|
||
const from = apiBehaviour.OriginRequestPolicyId;
|
||
apiBehaviour.OriginRequestPolicyId = orpId;
|
||
changes.push(
|
||
`${PATH_PATTERN} OriginRequestPolicyId ${from} -> ${orpId} (${ORP_NAME})`,
|
||
);
|
||
/* Printed at the moment of the change, not only in the runbook, because the
|
||
operator who needs it most is the one who did not read Part 3 first.
|
||
|
||
⚠️ IT NAMED THE OLD ID AS `Managed-AllViewerExceptHostHeader` WITHOUT
|
||
CHECKING, and printed an empty string when the behaviour carried no
|
||
policy at all — an "id" an operator would paste into a rollback. It now
|
||
says only what it read, and says so when it read nothing.
|
||
|
||
⚠️ AND IT SAID "and re-apply", WHICH NAMES THIS SCRIPT. Re-running with
|
||
--apply RE-ATTACHES the whitelist: section 5 converges forward and does
|
||
not know a revert from a first run (they are byte-identical in the
|
||
config). The rollback is a direct `update-distribution`, and the runbook
|
||
says so in the sentence under its code block; this line no longer
|
||
contradicts it. */
|
||
console.log(
|
||
from
|
||
? ` ↩ ROLLBACK for ${PATH_PATTERN}: PUT OriginRequestPolicyId back to ${from}` +
|
||
`${from === allViewerExceptHost ? ' (Managed-AllViewerExceptHostHeader)' : ''}` +
|
||
' with update-distribution --if-match. Do NOT re-run this script to' +
|
||
' roll back — it would re-attach the whitelist.'
|
||
: ` ↩ ROLLBACK for ${PATH_PATTERN}: the behaviour carried NO origin request` +
|
||
' policy before this change. Remove the field with' +
|
||
' update-distribution --if-match; do NOT re-run this script.',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
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 (parked.length) {
|
||
console.log(
|
||
`· ${parked.length} thing(s) PARKED — not available on this distribution's pricing plan:`,
|
||
);
|
||
for (const k of parked) console.log(` – ${k}`);
|
||
console.log(
|
||
' This is expected and does not affect the exit status. AGENTS.md §7',
|
||
);
|
||
console.log(' records the plan; flip PLAN_ALLOWS_CUSTOM_POLICIES if it');
|
||
console.log(' changes. Sections 1-3 are unaffected.');
|
||
console.log('');
|
||
}
|
||
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, or on ${PATH_PATTERN} forwarding`,
|
||
);
|
||
console.log(' CloudFront-Viewer-Address — the skip above says which.');
|
||
console.log('');
|
||
}
|
||
/* ⚠️ A SKIPPED SECTION MUST NOT EXIT 0. `docs/09` uses `exit 0` as its own
|
||
success stamp throughout, so a partial run that returned 0 read as a complete
|
||
one — and two of the skip paths here are recent (a breached limit, a broken
|
||
handler probe), where the same conditions previously surfaced as a failed AWS
|
||
call, which is loud. 3 means: sections that could run did, something was
|
||
skipped, read the ⚠ block. */
|
||
const EXIT_SKIPPED = 3;
|
||
const exitCode = skipped.length ? EXIT_SKIPPED : 0;
|
||
|
||
if (changes.length === 0) {
|
||
console.log(
|
||
skipped.length
|
||
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry everything this script manages.'
|
||
: parked.length
|
||
? 'NOTHING TO CHANGE — the distribution carries everything this script can apply on the current pricing plan. The parked items above are not among them.'
|
||
: 'NOTHING TO CHANGE — the distribution already carries all five.',
|
||
);
|
||
process.exit(exitCode);
|
||
}
|
||
console.log(
|
||
`${changes.length} change(s) to distribution ${DIST} (ETag ${etag}):`,
|
||
);
|
||
for (const c of changes) console.log(` + ${c}`);
|
||
console.log('');
|
||
|
||
if (!APPLY) {
|
||
console.log('DRY RUN — nothing was sent. Re-run with --apply to write it.');
|
||
process.exit(exitCode);
|
||
}
|
||
|
||
const res = aws([
|
||
'cloudfront',
|
||
'update-distribution',
|
||
'--id',
|
||
DIST,
|
||
'--if-match',
|
||
etag,
|
||
'--distribution-config',
|
||
JSON.stringify(cfg),
|
||
'--output',
|
||
'json',
|
||
]);
|
||
console.log(
|
||
`APPLIED. Status=${res.Distribution.Status} ETag=${res.ETag}\n` +
|
||
'CloudFront takes a few minutes to deploy. Wait for Deployed, then run the ' +
|
||
"runbook's verification block:\n" +
|
||
` aws cloudfront wait distribution-deployed --id ${DIST}`,
|
||
);
|
||
process.exit(exitCode);
|