Files
adr-sml/infra/cloudfront/policy-shapes.mjs
T
Pouya LajevardiandClaude Opus 5 a07193d561
Build and deploy / build-and-deploy (push) Failing after 4s
fix: pre-flight the CloudFront payload limits the dry run is the only guard for
The second `--apply` of 2026-09-04 created adr-sml-pdf-noindex and then failed
at create-origin-request-policy: InvalidArgument, "The parameter Comment is too
big" — 182 characters against a 128 cap. update-distribution never ran, so the
distribution is unchanged, but the account was left holding an orphaned policy.

Nothing local could have caught it, and that is now measured rather than
assumed: botocore/validate.py checks neither `max` nor `pattern` (range_check
reads only `min`; the word `pattern` does not appear in the file), and the 128
is not modelled as a constraint at all — `Comment` is a bare `string` and the
cap lives in the shape's documentation prose. So the dry run really is the only
pre-flight, and it now enforces PAYLOAD_LIMITS: 13 entries across both policy
payloads and the function ARN, each with the source it came from.

The entries that matter guard CLONED values rather than literals this file
authors — a literal is reviewed when it is written, while a value copied out of
the default behaviour's policy changes with no diff here. The API declares
TooLongCSPInResponseHeadersPolicy for exactly that case and docs/05 already
specifies a CSP that would land there. RemoveHeadersConfig is a recorded gap:
its cap is real but unpublished, and inventing a number would be worse.

Both comments are now 76 and 74 characters. `--function-arn` is validated
before any AWS call, and an unrecognised `--flag` is a usage error — the `=`
form was invisible to the parser and to the presence check, for a clean exit 0
with no router attached. A skipped section now exits 3, because docs/09 uses
exit 0 as its own success stamp and a partial run read as a complete one.

Confirmed by measurement, as asked: the next run REUSES the orphan by name,
matches every reconciled field, and stages it — create line gone, 4 changes
down to 3, no duplicate and no collision.

Reviewed twice. Round 2 found that the §7 record broke the table it lives in,
and that two comments asserted behaviour the code did not have. 17 findings
across both rounds, all fixed.

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:24:33 -04:00

319 lines
12 KiB
JavaScript

/**
* Shape helpers for the CloudFront policy configs `configure.mjs` builds.
*
* ⚠️ **A POLICY AWS HANDS BACK IS NOT A POLICY AWS WILL ACCEPT.**
* `get-response-headers-policy` returns `{}` for a member the source does not
* define — `Managed-SecurityHeadersPolicy` does it for `ContentSecurityPolicy`
* — and sending that back fails `create-response-headers-policy` on
* ParamValidation before the call leaves the machine. `docs/09` Part 3 carries
* the incident and the exact error.
*
* **Dropping an empty member is safe at every depth, and that is a measurement
* rather than a hope.** Of the 16 structures reachable from
* `ResponseHeadersPolicyConfig` in the CLI's own service model, **15 declare at
* least one required field** — so `{}` is not a legal value there and can only
* be the placeholder. The single exception is `SecurityHeadersConfig` itself,
* and `configure.mjs` skips before it can build one of those empty, because a
* PDF policy cloning no security headers is the thing that section exists to
* avoid.
*
* They live in their own module so they can be tested: `configure.mjs` reads
* argv and calls AWS at import time, so importing THAT to reach two pure
* functions is not possible. Same reason `fields.mjs` sits beside
* `handler.mjs`. See `policy-shapes.test.mjs`.
*/
/**
* Every empty-object member removed, at every depth, bottom-up — so a member
* left empty by stripping its own children is removed in turn.
*
* Arrays are recursed into but never have elements removed: an element index is
* load-bearing against its `Quantity` sibling, and an empty object inside one
* would be this script's own construction rather than an AWS placeholder. That
* case is left for `emptyObjectPaths` to report.
*/
export const withoutEmptyMembers = (value) => {
if (Array.isArray(value)) return value.map(withoutEmptyMembers);
if (!value || typeof value !== 'object') return value;
const out = {};
for (const [k, v] of Object.entries(value)) {
const cleaned = withoutEmptyMembers(v);
const isEmptyObject =
cleaned &&
typeof cleaned === 'object' &&
!Array.isArray(cleaned) &&
Object.keys(cleaned).length === 0;
if (!isEmptyObject) out[k] = cleaned;
}
return out;
};
/** True for `{}` — the value AWS accepts nowhere in these configs. */
export const isEmptyObject = (v) =>
Boolean(v) &&
typeof v === 'object' &&
!Array.isArray(v) &&
Object.keys(v).length === 0;
/**
* The dotted path of every empty object left in a config. A post-condition on
* the strip above, not a filter: if this returns anything, the strip did not do
* what this module claims it does.
*
* Empty ARRAYS are not reported — `{Quantity: 0, Items: []}` is valid and
* common, while an empty object is valid nowhere.
*/
export function emptyObjectPaths(value, path = '') {
if (Array.isArray(value)) {
return value.flatMap((v, i) => emptyObjectPaths(v, `${path}[${i}]`));
}
if (value && typeof value === 'object') {
if (Object.keys(value).length === 0) return [path || '(root)'];
return Object.entries(value).flatMap(([k, v]) =>
emptyObjectPaths(v, path ? `${path}.${k}` : k),
);
}
return [];
}
/**
* ⚠️ **NOTHING LOCAL ENFORCES ANY OF THESE, WHICH IS WHY THIS TABLE EXISTS.**
* Measured 2026-09-04: `botocore/validate.py` checks **neither `max` nor
* `pattern`** — `range_check()` reads only `min`, and the word `pattern` does
* not appear in the file — and the caps that matter are not modelled as
* constraints anyway. On both policy configs `Comment` is a bare `string`, and
* the 128 lives in the shape's **`documentation` prose**. So a 182-character
* `Comment` left the machine unremarked and came back `InvalidArgument`, after
* section 4 had already created its policy. **Every limit here is enforced by
* the service and by nothing else**, which makes the dry run the only
* pre-flight there is. `docs/09` Part 3 carries both attempts.
*
* ⚠️ **THE ENTRIES THAT MATTER MOST GUARD *CLONED* VALUES, NOT LITERALS THIS
* FILE AUTHORS.** A literal we write is reviewed when it is written; a value
* copied out of the default behaviour's policy changes without anyone here
* touching it, and `docs/05` already specifies a Content-Security-Policy that
* would land there. `CreateResponseHeadersPolicy` declares a dedicated error
* for exactly that — `TooLongCSPInResponseHeadersPolicy`.
*
* ⚠️ **KNOWN GAP, RECORDED RATHER THAN GUESSED: `RemoveHeadersConfig` is cloned
* too and its count cap is not published.** The operation declares
* `TooManyRemoveHeadersInResponseHeadersPolicy`, so a cap exists; the quotas
* page states no number and inventing one would be worse than the gap. A breach
* there surfaces as that error at the write, not as a pre-flight skip.
*
* ⚠️ **ABSENCE FROM A SOURCE IS NOT ABSENCE OF A LIMIT.** Entries marked
* `[assumed]` have no AWS source at all; they are kept because they cost nothing
* and constrain nothing this script sends.
*/
export const PAYLOAD_LIMITS = {
'response-headers-policy': [
{
path: 'Name',
rule: 'maxLength',
limit: 128,
source:
'[assumed] — no AWS source states a policy name length; the documented Name rule is uniqueness. Pouya, 2026-09-04',
},
{
path: 'Comment',
rule: 'maxLength',
limit: 128,
source:
'service model, ResponseHeadersPolicyConfig.Comment documentation: "The comment cannot be longer than 128 characters"',
},
{
/* CLONED, not authored here — see the header. */
path: 'SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy',
rule: 'maxLength',
limit: 1783,
source:
'CloudFront quotas, Quotas on headers: "Maximum length of the Content-Security-Policy header value | 1,783 characters"; error shape TooLongCSPInResponseHeadersPolicy',
},
{
path: 'CustomHeadersConfig.Items[].Header',
rule: 'maxLength',
limit: 256,
source:
'CloudFront quotas, Quotas on headers: "Custom headers: maximum length of a header name | 256 characters"',
},
{
path: 'CustomHeadersConfig.Items[].Value',
rule: 'maxLength',
limit: 1783,
source:
'CloudFront quotas, Quotas on headers: "Custom headers: maximum length of a header value | 1,783 characters"',
},
{
path: 'CustomHeadersConfig.Items[]',
rule: 'maxCount',
limit: 10,
source:
'CloudFront quotas: "maximum number of custom headers that you can add to a response headers policy | 10" (adjustable); error shape TooManyCustomHeadersInResponseHeadersPolicy',
},
{
paths: [
'CustomHeadersConfig.Items[].Header',
'CustomHeadersConfig.Items[].Value',
],
rule: 'maxCombinedLength',
limit: 10240,
source:
'CloudFront quotas: "Custom headers: maximum length of all header values and names combined | 10,240 characters"',
},
],
'origin-request-policy': [
{
path: 'Name',
rule: 'maxLength',
limit: 128,
source: '[assumed] — see the response-headers-policy Name entry',
},
{
path: 'Comment',
rule: 'maxLength',
limit: 128,
source:
'service model, OriginRequestPolicyConfig.Comment documentation: "The comment cannot be longer than 128 characters". This is the one that failed on 2026-09-04 at 182',
},
{
path: 'HeadersConfig.Headers.Items[]',
rule: 'maxCount',
limit: 10,
source:
'CloudFront quotas: "Headers per origin request policy | 10" (adjustable); error shape TooManyHeadersInOriginRequestPolicy. We send 5',
},
{
paths: ['HeadersConfig.Headers.Items[]'],
rule: 'maxCombinedLength',
limit: 1024,
source:
'CloudFront quotas: "Total combined length of all query string, header, and cookie names in an origin request policy | 1024". We contribute header names only',
},
],
/* Checked as a flag before any AWS call, because by the time a distribution
payload exists sections 4 and 5 may already have created policies.
⚠️ NOT DECORATION. `aws cloudfront list-functions --output text` returns the
ARN twice, tab-joined, because the function exists in a DEVELOPMENT and a
LIVE stage — 113 characters, and it fails the pattern too. Staging that
replaces a working `router.js` association with a value CloudFront will not
accept, and `router.js` keeps 22 of 23 pages off S3's AccessDenied.
`docs/09` Part 2 derives it correctly with `describe-function --stage LIVE`. */
'function-association': [
{
path: 'FunctionARN',
rule: 'maxLength',
limit: 108,
source: "service model, shape FunctionARN: {'max': 108}",
},
{
path: 'FunctionARN',
rule: 'pattern',
limit: 'arn:aws:cloudfront::[0-9]{12}:function\\/[a-zA-Z0-9-_]{1,64}',
source: 'service model, shape FunctionARN: pattern',
},
],
};
/**
* Resolve a dotted path, where `[]` means "every element of this array". Always
* returns `{path, value}` pairs with the index substituted, so a violation
* names the element rather than the collection.
*/
function resolvePath(root, path) {
let frontier = [{ path: '', value: root }];
for (const segment of path.split('.')) {
const next = [];
const isArray = segment.endsWith('[]');
const key = isArray ? segment.slice(0, -2) : segment;
for (const { path: p, value } of frontier) {
const child = value?.[key];
const here = p ? `${p}.${key}` : key;
if (child === undefined || child === null) continue;
if (isArray) {
if (!Array.isArray(child)) continue;
child.forEach((v, i) => next.push({ path: `${here}[${i}]`, value: v }));
} else {
next.push({ path: here, value: child });
}
}
frontier = next;
}
return frontier;
}
/**
* Every limit the given payload breaches. Empty means it is safe to send as far
* as this table knows — which is a claim about the table, not about AWS.
*/
export function limitViolations(kind, payload) {
const rules = PAYLOAD_LIMITS[kind];
if (!rules) throw new Error(`no limit table for payload kind '${kind}'`);
const out = [];
const add = (v) => out.push(v);
for (const rule of rules) {
/* `paths` (plural) is for the aggregate rules, where AWS caps a total
across more than one field — header names AND values combined. */
const paths = rule.paths ?? [rule.path];
const label = paths.join(' + ');
const resolved = paths.flatMap((one) => resolvePath(payload, one));
if (rule.rule === 'maxCount') {
if (resolved.length > rule.limit) {
add({
path: label,
rule: 'maxCount',
actual: resolved.length,
limit: rule.limit,
message: `${label} has ${resolved.length} entries; the limit is ${rule.limit} (${rule.source})`,
});
}
continue;
}
if (rule.rule === 'maxCombinedLength') {
const total = resolved.reduce(
(n, { value }) => n + (typeof value === 'string' ? value.length : 0),
0,
);
if (total > rule.limit) {
add({
path: label,
rule: 'maxCombinedLength',
actual: total,
limit: rule.limit,
message: `${label} totals ${total} characters; the limit is ${rule.limit} (${rule.source})`,
});
}
continue;
}
for (const { path, value } of resolved) {
if (typeof value !== 'string') continue;
if (rule.rule === 'maxLength' && value.length > rule.limit) {
add({
path,
rule: 'maxLength',
actual: value.length,
limit: rule.limit,
message: `${path} is ${value.length} characters; the limit is ${rule.limit} (${rule.source})`,
});
}
if (
rule.rule === 'pattern' &&
!new RegExp(`^(?:${rule.limit})$`).test(value)
) {
add({
path,
rule: 'pattern',
actual: JSON.stringify(value),
limit: rule.limit,
message: `${path} does not match ${rule.limit} (${rule.source})`,
});
}
}
}
return out;
}