/** * 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; }