Files
adr-sml/infra/cloudfront/configure.mjs
T
Pouya LajevardiandClaude Opus 5 07a8ff6989
Build and deploy / build-and-deploy (push) Failing after 4s
fix: assert which aws binary runs, because this script rewrites the whole config
Round 2 returned an addendum after the previous commit, having re-checked its
own instrument. Two findings, one blocking on the next --apply.

This machine carries two AWS CLIs on PATH: /opt/homebrew/bin/aws 2.34.53 and
/usr/local/bin/aws 2.11.15 (April 2023). configure.mjs called bare `aws`, so
PATH decided. That matters because update-distribution is a full replace and
botocore parses a config against its own model, dropping members it does not
know — an old CLI reads a lossy config and writes the loss back, and --if-match
cannot catch it because the ETag is genuinely current.

Measured, which the review could not do: the older model is missing nine
members, and E1OK7G98KNKUTA carries two of them — GrpcConfig on the default
behaviour and on /api/*, both {Enabled: false}. So a round trip through the old
CLI would write back the same effective value and change nothing observable
today. That is precisely why it needed a guard rather than a look: nothing
reports it when that stops being true.

The script now resolves the binary, prints it and its version as the first line
of output, and exits 2 below a floor before any AWS call. Verified both ways.

Second finding: entry (av) claimed "no client-side validation of any kind"
stood between the 182-character Comment and the API, from reading one
validate.py without naming which CLI it came from — and the other install is
frozen, so it is unchecked rather than confirmed. That is CLAUDE.md's named
shape. Both records now name the instrument and version and lead with the claim
that needs no qualification: the Comment reached the API and came back
InvalidArgument, so nothing stopped it on the CLI that ran.

The second install's model independently confirms FunctionARN max 108, its
pattern, and the 128 on both Comment members.

Nothing was applied to the distribution and nothing was deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-04 14:32:11 -04:00

1220 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Applies the five 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
* 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 13 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;
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 (!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 (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 all five.'
: '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);