fix: pre-flight the CloudFront payload limits the dry run is the only guard for
Build and deploy / build-and-deploy (push) Failing after 4s

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
This commit is contained in:
Pouya Lajevardi
2026-09-04 14:24:33 -04:00
co-authored by Claude Opus 5
parent bbe535d158
commit a07193d561
5 changed files with 1092 additions and 168 deletions
+132 -1
View File
@@ -725,7 +725,7 @@ the audience it targets. Revisit at month 1218. `[verified 2026-08-25 — dec
| AWS account | `327082975128` `[verified 2026-08-26 — inventory]` | | AWS account | `327082975128` `[verified 2026-08-26 — inventory]` |
| Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` | | Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` |
| S3 bucket | **`adr-smlcompany-site`** — versioning **Enabled**, so rollback works `[verified 2026-08-26]` | | S3 bucket | **`adr-smlcompany-site`** — versioning **Enabled**, so rollback works `[verified 2026-08-26]` |
| CloudFront | **`E1OK7G98KNKUTA`**, alias `adr.smlcompany.ca`, origin `adr-smlcompany-site.s3.ca-central-1.amazonaws.com` with OAC **`E13GAFUL6UQP6R`**, Deployed `[verified 2026-08-26; config re-read 2026-09-01]`. Default behaviour: `Managed-CachingOptimized`, `Managed-SecurityHeadersPolicy`, methods HEAD/GET, `redirect-to-https`, default root object `index.html`. 🟢 **ALL THREE ARE NOW PRESENT — `docs/09` Parts 13 ran at cutover.** `[verified 2026-09-04 — get-distribution-config]`: **1** function association (`adr-sml-router`, viewer-request), **1** custom error response (404 → `/404.html`, status 404), **1** cache behaviour (`/api/*``intake-api`), **2** origins. ⚠️ **THIS ROW READ "THREE THINGS THE SITE NEEDS ARE ABSENT … 22 of the 23 pages do not serve" UNTIL 2026-09-04**, stamped `[verified 2026-09-01]` — true when written, false from the moment Part 3 ran, and asserting that the live site does not serve. **Still to apply, and they are the only two outstanding:** the `*.pdf` behaviour with `adr-sml-pdf-noindex` (section 4) and the `/api/*` origin request policy `adr-sml-api-viewer-address` (section 5). Both need `configure.mjs --apply`, not a deploy. ⚠️ **AN `--apply` WAS ATTEMPTED ON 2026-09-04 AND FAILED ON ITS FIRST WRITE — so read "still to apply" as "attempted and rejected", not "never tried".** The state recorded above is unchanged and was **verified rather than assumed** after the failure: `/api/*` still on `b689b0a8-…`, **no** `*.pdf` behaviour, **zero** custom response-headers policies and **zero** custom origin request policies `[verified 2026-09-04 — get-distribution-config, list-response-headers-policies --type custom, list-origin-request-policies --type custom]`**both** policy lists, because sections 4 and 5 each create one **before** `update-distribution` and a throw between them can orphan a policy. `update-distribution` is the script's last call, so a throw above it leaves the **distribution** untouched; it says nothing about the account, and this row cited only one of the two list calls for one round. Exact error, cause and fix: `docs/09` Part 3's incident record. The original text, because the trailing-slash reasoning is what makes the router load-bearing: *"the first is why 22 of the 23 pages do not serve — `astro.config.mjs` sets `trailingSlash: 'always'` with `build.format: 'directory'`, so CloudFront asks S3 for the key `about/`, which does not exist."* `infra/cloudfront/` holds the function and the config script | | CloudFront | **`E1OK7G98KNKUTA`**, alias `adr.smlcompany.ca`, origin `adr-smlcompany-site.s3.ca-central-1.amazonaws.com` with OAC **`E13GAFUL6UQP6R`**, Deployed `[verified 2026-08-26; config re-read 2026-09-01]`. Default behaviour: `Managed-CachingOptimized`, `Managed-SecurityHeadersPolicy`, methods HEAD/GET, `redirect-to-https`, default root object `index.html`. 🟢 **ALL THREE ARE NOW PRESENT — `docs/09` Parts 13 ran at cutover.** `[verified 2026-09-04 — get-distribution-config]`: **1** function association (`adr-sml-router`, viewer-request), **1** custom error response (404 → `/404.html`, status 404), **1** cache behaviour (`/api/*``intake-api`), **2** origins. ⚠️ **THIS ROW READ "THREE THINGS THE SITE NEEDS ARE ABSENT … 22 of the 23 pages do not serve" UNTIL 2026-09-04**, stamped `[verified 2026-09-01]` — true when written, false from the moment Part 3 ran, and asserting that the live site does not serve. **Still to apply, and they are the only two outstanding:** the `*.pdf` behaviour with `adr-sml-pdf-noindex` (section 4) and the `/api/*` origin request policy `adr-sml-api-viewer-address` (section 5). Both need `configure.mjs --apply`, not a deploy. 🛑 **TWO `--apply` ATTEMPTS WERE MADE ON 2026-09-04 AND BOTH FAILED BEFORE `update-distribution`. THE DISTRIBUTION IS UNCHANGED; THE ACCOUNT IS NOT.** Read "still to apply" as "attempted twice and rejected twice", never as "never tried". **Attempt 1** — section 4, `create-response-headers-policy`, `ParamValidation`: the cloned `SecurityHeadersConfig` carried `"ContentSecurityPolicy": {}`. Nothing was created. **Attempt 2** — section 4 **succeeded** and created the policy; section 5 then failed at `create-origin-request-policy` with `InvalidArgument`, *"The parameter Comment is too big"* — a **182-character** `Comment` against a **128** cap that the service model does not carry, so no client-side check saw it. 🟡 **THE ACCOUNT THEREFORE HOLDS ONE ORPHANED POLICY, AND IT IS EXPECTED, HARMLESS AND MUST NOT BE DELETED BY HAND:** `adr-sml-pdf-noindex` = **`51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**, referenced by no behaviour `[verified 2026-09-04]`. ⚠️ **THE SELF-HEALING CLAIM IS NOW MEASURED RATHER THAN ASSERTED** — this row and `docs/09` Part 3 predicted this exact outcome before it happened, and the next dry run **finds it by name, matches it on every reconciled field, and stages it for attachment**: `· response-headers policy adr-sml-pdf-noindex exists and matches the default behaviour`, with the real id in the change line and the create line **gone** — 4 changes down to 3 `[measured 2026-09-04, dry run, exit 0, nothing written]`. **No duplicate, no name collision, no error.** ⚠️ **`Comment` IS NOT A RECONCILED FIELD**, so `51c4e79b` keeps the original 118-character text while `configure.mjs` now carries a shorter one. Deliberate: adding `Comment` to the drift check would throw on this very policy and block the run that attaches it. Cosmetic, and it resolves only if the policy is ever recreated. State after both attempts, **verified rather than assumed**: `/api/*` still on `b689b0a8-…`, **no** `*.pdf` behaviour, **one** custom response-headers policy (the orphan above), **zero** custom origin request policies `[verified 2026-09-04 — get-distribution-config, list-response-headers-policies --type custom, list-origin-request-policies --type custom]`**both** policy lists, because sections 4 and 5 each create one **before** `update-distribution`. `update-distribution` is the script's last call, so a throw above it leaves the **distribution** untouched; it says nothing about the account, and this row cited only one of the two list calls for one round. Exact error, cause and fix: `docs/09` Part 3's incident record. The original text, because the trailing-slash reasoning is what makes the router load-bearing: *"the first is why 22 of the 23 pages do not serve — `astro.config.mjs` sets `trailingSlash: 'always'` with `build.format: 'directory'`, so CloudFront asks S3 for the key `about/`, which does not exist."* `infra/cloudfront/` holds the function and the config script |
| ACM certificate | `arn:aws:acm:us-east-1:327082975128:certificate/2b6d5bdf-6790-430c-9b82-c00ab66e6d87` — ISSUED `[verified 2026-08-26]` | | ACM certificate | `arn:aws:acm:us-east-1:327082975128:certificate/2b6d5bdf-6790-430c-9b82-c00ab66e6d87` — ISSUED `[verified 2026-08-26]` |
| Intake API | `adr-intake-api`, HTTP API `4tl0m5igkj`, endpoint `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com`. 🟢 **LIVE. ONE ROUTE, `POST /api/intake`** → integration `0ftgjgv` (`AWS_PROXY`, payload format **2.0**, which is the format `handler.mjs` reads). Stage `$default`, auto-deploy on, no access log. **THROTTLED per route: `POST /api/intake` → rate 1.0 req/s, burst 5, detailed metrics on** — the aggregate throttle `docs/05` §Validation specifies, `docs/09` Part 6.3, and it is a ROUTE setting rather than the stage default `[verified 2026-09-04 — get-routes, get-stage]`. `DisableExecuteApiEndpoint` is **false** and must stay false: the CloudFront origin **is** that hostname. ⚠️ **THIS ROW READ "One route, `POST /submissions`" AND "The form's route (`POST /api/intake`) does not exist yet" UNTIL 2026-09-04, TWO DAYS AFTER CUTOVER** — the old route was retired by `docs/09` Part 6.4 and the row was never re-read. It also said **no throttling**, which was true of the stage default and false of the route, and a query projecting `DefaultRouteSettings` alone reproduces that error exactly: **read `RouteSettings` too before concluding a throttle is absent** | | Intake API | `adr-intake-api`, HTTP API `4tl0m5igkj`, endpoint `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com`. 🟢 **LIVE. ONE ROUTE, `POST /api/intake`** → integration `0ftgjgv` (`AWS_PROXY`, payload format **2.0**, which is the format `handler.mjs` reads). Stage `$default`, auto-deploy on, no access log. **THROTTLED per route: `POST /api/intake` → rate 1.0 req/s, burst 5, detailed metrics on** — the aggregate throttle `docs/05` §Validation specifies, `docs/09` Part 6.3, and it is a ROUTE setting rather than the stage default `[verified 2026-09-04 — get-routes, get-stage]`. `DisableExecuteApiEndpoint` is **false** and must stay false: the CloudFront origin **is** that hostname. ⚠️ **THIS ROW READ "One route, `POST /submissions`" AND "The form's route (`POST /api/intake`) does not exist yet" UNTIL 2026-09-04, TWO DAYS AFTER CUTOVER** — the old route was retired by `docs/09` Part 6.4 and the row was never re-read. It also said **no throttling**, which was true of the stage default and false of the route, and a query projecting `DefaultRouteSettings` alone reproduces that error exactly: **read `RouteSettings` too before concluding a throttle is absent** |
| Intake Lambda | `adr-intake-handler`, `nodejs24.x`, **arm64**, role `adr-intake-lambda-role`. 🟢 **LIVE — IT IS `backend/intake/handler.mjs`, NOT THE HAND-BUILT FUNCTION.** Handler **`handler.handler`**, timeout **15 s**, memory **512 MB**, **six environment variables** (`INTAKE_TABLE`, `MAIL_FROM`, `NOTIFY_TO`, `NO_RETAINER_NOTICE`, `RESPONSE_TIME`, `SITE_ORIGIN`), no DLQ, code **3,314,989 bytes**, `CodeSha256` **`oMVpQYFxvjGCA6Wbz6UQGnQlS2QgVndT4BdoiCqsP1o=`**, last modified **2026-09-04T15:53:36Z** `[verified 2026-09-04 — get-function-configuration]`. ⚠️ **THAT CODE SIZE MEANS THE BUNDLED VARIANT, `docs/09` §5.5 — the runtime does not supply the SDK v3 clients.** The deployed artefact was downloaded via `get-function` `Code.Location` and read: it holds `handler.mjs`, `fields.mjs`, **`spam-score.mjs`**, `node_modules/` and `package.json`, and **all three source files are byte-identical to commit `3c3ba5d`** — sha1 `12cb09a8…`, `d0c0d15f…` and `5b4f9210…` `[verified 2026-09-04, after the 15:53Z redeploy]`. The archive holds **4,277 zip entries: 3,575 files and 702 directories**, 4,273 of the entries under `node_modules/`. ⚠️ **THIS ROW CALLED 4,277 A FILE COUNT** — that is `unzip -l`'s own footer wording, which prints `4277 files`, and 702 of them are directories. An operator re-deriving it with the obvious instrument, `find -type f`, gets **3,575** and a 702-entry mismatch against this register with no way to tell that from a truncated bundle. **Say what a number is a number OF** (`adversarial-reviewer`; recounted from the zip's central directory). ⚠️ **EVERY FIGURE IN THE TWO SENTENCES ABOVE MOVED WITH THAT REDEPLOY**, and nothing in the repository would have said so — the zip is built from the working tree and uploaded, and no committed file records what is running. The previous staleness of this row lasted **two days**; this one was caught **within the hour**, and the difference was that someone happened to be reading the row. It is what an operator consults before touching the Lambda, so **re-read it from `get-function-configuration` after every Part 5 run** rather than at the next convenient session. ⚠️ **THIS SAID "byte-identical to `HEAD`" AND `HEAD` IS A MOVING TARGET**: the 2026-09-04 change set edits both files and adds a third, so the sentence would have become false at its own commit while reading as current. **A digest claim about a deployed artefact names the commit it matched, never a ref.** The working tree is NOT what is running until `docs/09` Part 5 runs again. 🛑 **THE PACKAGE FILE LIST FOLLOWS NO IMPORT — it is `ls *.mjs` minus the tests, in both §5.1 and §5.5** (hand-typed in both until 2026-09-04, with nothing checking they agreed). `spam-score.mjs` joined it 2026-09-04 and a zip missing a module fails at cold start. Resource policy: **one** statement, `apigw-post-api-intake`, `apigateway.amazonaws.com` conditioned on `…/4tl0m5igkj/*/POST/api/intake` `[verified 2026-09-04 — get-policy]`. Execution role: `dynamodb:PutItem` on the table (write-only — it cannot read it), `ses:SendEmail`/`SendRawEmail`, plus `AWSLambdaBasicExecutionRole`. ⚠️ **THIS ROW SAID `index.handler`, 10 s, 128 MB, NO ENVIRONMENT VARIABLES, 1,527 BYTES AND "nothing has been deployed (D11)" UNTIL 2026-09-04**, stamped `[verified 2026-09-01]` — true when written, false from the moment Part 5 ran, and it is the row an operator reads before touching production. Deployment commands: `docs/09` Part 5 | | Intake Lambda | `adr-intake-handler`, `nodejs24.x`, **arm64**, role `adr-intake-lambda-role`. 🟢 **LIVE — IT IS `backend/intake/handler.mjs`, NOT THE HAND-BUILT FUNCTION.** Handler **`handler.handler`**, timeout **15 s**, memory **512 MB**, **six environment variables** (`INTAKE_TABLE`, `MAIL_FROM`, `NOTIFY_TO`, `NO_RETAINER_NOTICE`, `RESPONSE_TIME`, `SITE_ORIGIN`), no DLQ, code **3,314,989 bytes**, `CodeSha256` **`oMVpQYFxvjGCA6Wbz6UQGnQlS2QgVndT4BdoiCqsP1o=`**, last modified **2026-09-04T15:53:36Z** `[verified 2026-09-04 — get-function-configuration]`. ⚠️ **THAT CODE SIZE MEANS THE BUNDLED VARIANT, `docs/09` §5.5 — the runtime does not supply the SDK v3 clients.** The deployed artefact was downloaded via `get-function` `Code.Location` and read: it holds `handler.mjs`, `fields.mjs`, **`spam-score.mjs`**, `node_modules/` and `package.json`, and **all three source files are byte-identical to commit `3c3ba5d`** — sha1 `12cb09a8…`, `d0c0d15f…` and `5b4f9210…` `[verified 2026-09-04, after the 15:53Z redeploy]`. The archive holds **4,277 zip entries: 3,575 files and 702 directories**, 4,273 of the entries under `node_modules/`. ⚠️ **THIS ROW CALLED 4,277 A FILE COUNT** — that is `unzip -l`'s own footer wording, which prints `4277 files`, and 702 of them are directories. An operator re-deriving it with the obvious instrument, `find -type f`, gets **3,575** and a 702-entry mismatch against this register with no way to tell that from a truncated bundle. **Say what a number is a number OF** (`adversarial-reviewer`; recounted from the zip's central directory). ⚠️ **EVERY FIGURE IN THE TWO SENTENCES ABOVE MOVED WITH THAT REDEPLOY**, and nothing in the repository would have said so — the zip is built from the working tree and uploaded, and no committed file records what is running. The previous staleness of this row lasted **two days**; this one was caught **within the hour**, and the difference was that someone happened to be reading the row. It is what an operator consults before touching the Lambda, so **re-read it from `get-function-configuration` after every Part 5 run** rather than at the next convenient session. ⚠️ **THIS SAID "byte-identical to `HEAD`" AND `HEAD` IS A MOVING TARGET**: the 2026-09-04 change set edits both files and adds a third, so the sentence would have become false at its own commit while reading as current. **A digest claim about a deployed artefact names the commit it matched, never a ref.** The working tree is NOT what is running until `docs/09` Part 5 runs again. 🛑 **THE PACKAGE FILE LIST FOLLOWS NO IMPORT — it is `ls *.mjs` minus the tests, in both §5.1 and §5.5** (hand-typed in both until 2026-09-04, with nothing checking they agreed). `spam-score.mjs` joined it 2026-09-04 and a zip missing a module fails at cold start. Resource policy: **one** statement, `apigw-post-api-intake`, `apigateway.amazonaws.com` conditioned on `…/4tl0m5igkj/*/POST/api/intake` `[verified 2026-09-04 — get-policy]`. Execution role: `dynamodb:PutItem` on the table (write-only — it cannot read it), `ses:SendEmail`/`SendRawEmail`, plus `AWSLambdaBasicExecutionRole`. ⚠️ **THIS ROW SAID `index.handler`, 10 s, 128 MB, NO ENVIRONMENT VARIABLES, 1,527 BYTES AND "nothing has been deployed (D11)" UNTIL 2026-09-04**, stamped `[verified 2026-09-01]` — true when written, false from the moment Part 5 ran, and it is the row an operator reads before touching production. Deployment commands: `docs/09` Part 5 |
@@ -957,6 +957,137 @@ never being raised again.
# Change Log # Change Log
## 2026-09-04 (av) — The second `--apply` got one policy further and failed on a 128-character cap that nothing local enforces; the orphan it left is reused by name, measured; the dry run gains the only pre-flight this script has; and §7's own record broke the table it lives in
**Pouya's ruling of 2026-09-04**, after the second attempt: *"Fix: shorten BOTH
policy comments to well under 128 characters… Add a dry-run assertion enforcing
every CloudFront Comment field ≤128 characters — and while there, enumerate any
other length/format limit on the payloads we send… since the dry run is the only
pre-flight we have. Confirm on the next run section 4 REUSES 51c4e79b by name
rather than creating a duplicate or erroring on the name collision — measure it
in the dry run output. Record both failed attempts in docs/09 Part 3 and §7."*
---
### 1. What happened, and what it left behind
Attempt 2 got past attempt 1's `ParamValidation`: section 4 **created**
`adr-sml-pdf-noindex` = **`51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**. Section 5
then failed on a **182-character** `Comment` against a **128** cap —
`InvalidArgument`, *"The parameter Comment is too big"*. `update-distribution`
never ran. **The distribution is unchanged; the account is not.**
This is the orphan case `docs/09` Part 3 and §7 described **before it occurred**,
including the instruction not to delete it by hand.
### 2. The reuse is measured, not asserted
`[measured 2026-09-04, dry run, exit 0, nothing written]` — the next run finds
the orphan **by name**, matches every reconciled field, and stages it. The create
line is gone and the change count drops **4 → 3**, with the real id inline. No
duplicate, no collision — and a collision would not be silent either: a duplicate
name returns **`ResponseHeadersPolicyAlreadyExists`**, a different error from the
`InvalidArgument` above, and the script never reaches it because it looks the
policy up by name first.
### 3. Why nothing local caught a 182-character string — two measurements
1. **`botocore/validate.py` checks neither `max` nor `pattern`.** `range_check()`
reads `shape.metadata['min']` and nothing else; the word `pattern` does not
appear in the file. A **declared** constraint is not applied.
2. **The 128 is not declared as a constraint anyway.** On both policy configs
`Comment` is a bare `string`; the cap lives in the shape's **`documentation`
prose**.
⚠️ **BOTH CORRECTED CLAIMS IN THIS CHANGE SET'S OWN FIRST DRAFT.** The two
`FunctionARN` entries were marked `enforcedBy: 'client'` because the model
declares `{max: 108, pattern: …}`. And the first draft searched the model for
`min`/`max`/`pattern` **keys**, concluded it was silent on `Comment`, and sourced
128 as `[assumed]`. The model states it — in a field the search never read.
### 4. What was built
- **Both comments shortened** — 118 → **76**, 182 → **74**, each ~52 characters
clear of the cap.
- **`PAYLOAD_LIMITS`** in `policy-shapes.mjs`: **13** entries (7 + 4 + 2), four
rule kinds (`maxLength`, `pattern`, `maxCount`, `maxCombinedLength`), each
carrying its source. Count and combined-length quotas read off the CloudFront
quotas page; `Comment` off the model's own documentation.
- ⚠️ **THE ENTRIES THAT MATTER MOST GUARD *CLONED* VALUES.** Round 1's sharpest
finding: every original entry constrained a literal this file authors —
reviewed when written — while the values section 4 **copies** from the default
behaviour's policy had none. `CreateResponseHeadersPolicy` declares
`TooLongCSPInResponseHeadersPolicy` for exactly that, and `docs/05` already
specifies a CSP that would land there. The documented **1,783** cap is now
covered.
- ⚠️ **KNOWN GAP, RECORDED RATHER THAN GUESSED.** `RemoveHeadersConfig` is also
cloned and its count cap is **not published**; the operation declares
`TooManyRemoveHeadersInResponseHeadersPolicy`, so a cap exists. Inventing a
number would be worse than the gap.
- **`--function-arn` validated before any AWS call**, closing the gap flagged and
deliberately not built in entry (au). **An unrecognised `--flag` is now a usage
error too** — `flag()` reads only the space form, so `--function-arn=<arn>` was
invisible to it *and* to the presence check, for a clean exit 0 with no router
attached.
- **`Name` is `[assumed]` and says so.** No AWS source states a policy name
length; the documented `Name` rule is **uniqueness**.
- **Exit `3` when anything was skipped.** `docs/09` uses `exit 0` as its own
success stamp, so a partial run read as a complete one — and two skip paths are
new in this change set.
### 5. Two repairs contained defects worse than what they fixed
**A live-form-breaking one, found by its own probe.** Skipping only the *create*
left the dependent staging in place: an `--apply` would have set `/api/*`'s
`OriginRequestPolicyId` to **null**, removing header forwarding so every
submission fails while looking like the visitor's browser. Section 4 had the same
shape. Both now skip the whole section.
**And §7's own record broke the table it lives in** — the rewritten CloudFront
row carried five literal newlines, so GFM parsed it as one two-cell row plus five
single-cell rows, rendering the orphan warning and the policy id **in the label
column** while the CloudFront row's value cell ended mid-sentence. Round 2 caught
it. Rejoined as one row using plain spacing, not `<br>`: `AGENTS.md` contains no
HTML and this change set is not the place to introduce it.
### 6. Review — two rounds, and the cap held
`adversarial-reviewer` only; **`claims-auditor` did not run (D20)**, and this
change set reaches no public copy. **Round 1: 7 findings. Round 2: 10**, one
blocking, and **two of the ten were comments asserting things the code does not
do** — a comment naming an "EMPTY-OBJECT throw" that had been converted to a skip
a session earlier, and a comment promising the payload is checked-then-sent while
section 4 rebuilt a second literal at the call. That is D19's prediction in its
own terms. All 17 fixed, none declined. **No round 3.**
### 7. Verification
`policy-shapes.test.mjs` **53 cases**; **19 of 19 mutations killed, 0 survived**.
Two mutations survived earlier rounds and both were real coverage holes — the
response-headers caps and the cloned-CSP cap could each be raised tenfold without
failing anything, because every over-cap case had been written against the other
policy. Cases were added and both now die.
Fault-injected and measured, each exit status read: an over-long cloned `Comment`
**skips its whole section** (exit 3) while the other section still produces its
changes; a missing `handler.mjs` skips section 5 entirely instead of falling
through to the create; a broken reads-probe is caught rather than reporting
`covers all 0 headers`; `--function-arn=<arn>` and an empty `--function-arn` both
exit 2; the drift throw still fires; a matching policy still reports
`exists and matches`.
**Gates, exit status read for each:** `check` 0 · `build` 0 (23 pages) ·
`check:claims` 0 · `check:intake` 0 · `og:proof` 0 · `lint` 0 ·
`router.test` 0 (30/30) · `policy-shapes.test` 0 (53/53) · `spam-score.test` 0
(39/39) · minifier grep 1 (clean) · live dry run **0**, 59 lines, 3 changes, 0
skips, nothing written. **`lighthouse` not run** — nothing under `src/` or
`dist/` changed.
**Nothing was applied to the distribution and nothing was deployed. The orphaned
policy was not touched.**
---
## 2026-09-04 (au) — The first `--apply` failed on its first write because a policy AWS handed back is not a policy AWS accepts; the fix was wrong twice before it was right; and two production dependencies moved with no diff anywhere to show it ## 2026-09-04 (au) — The first `--apply` failed on its first write because a policy AWS handed back is not a policy AWS accepts; the fix was wrong twice before it was right; and two production dependencies moved with no diff anywhere to show it
**Pouya's ruling of 2026-09-04**, after `configure.mjs --apply` failed: *"Fix §4's **Pouya's ruling of 2026-09-04**, after `configure.mjs --apply` failed: *"Fix §4's
+96 -20
View File
@@ -323,19 +323,26 @@ node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN"
⚠️ **THE BLOCK BELOW IS THE `+` CHANGE LINES AND THE TWO RESOLVED-POLICY LINES. ⚠️ **THE BLOCK BELOW IS THE `+` CHANGE LINES AND THE TWO RESOLVED-POLICY LINES.
IT IS NOT THE WHOLE OUTPUT, AND IT SAID "exactly" UNTIL 2026-09-04.** Against IT IS NOT THE WHOLE OUTPUT, AND IT SAID "exactly" UNTIL 2026-09-04.** Against
the **live** distribution the dry run is **60 lines**, and all 60 account for: the **live** distribution the dry run is **59 lines**, and all 59 account for:
2 resolved-policy lines, **10** `·` lines, **4** `+` lines, a **40-line JSON 2 resolved-policy lines, **10** `·` lines, **3** `+` lines, a **40-line JSON
dump** of the `*.pdf` behaviour it would add, 2 blank lines, the dump** of the `*.pdf` behaviour it would add, 2 blank lines, the
`N change(s) to distribution …` header and the `DRY RUN — nothing was sent.` `N change(s) to distribution …` header and the `DRY RUN — nothing was sent.`
footer `[measured 2026-09-04, exit 0, nothing written]`. footer `[measured 2026-09-04, exit 0, nothing written]`.
⚠️ **THAT 10/4 SPLIT IS THE LIVE STATE, NOT THE STATE THE BLOCK BELOW 🛑 **READ THE EXIT STATUS, AND IT HAS THREE VALUES.** `0` — everything this
DESCRIBES.** Each of the four things Parts 13 have already applied prints `·` script manages was applied or is already present. `2` — a usage error, before any
when it is found and `+` when it is staged, so in the Part 0.3 state those four AWS call. **`3` — sections that could run did, and something was SKIPPED: read
move across: **6** `·` and **8** `+`, same 60 lines. **The total is not the the `⚠ … SKIPPED, not changed` block.** Anything else is a throw. `3` exists
check — the `+` lines are.** An operator told the output is "exactly" 14 lines because a skip used to exit `0`, and this document uses `exit 0` as its own
either stops on a wall of unexplained text or stops comparing, and the paragraph success stamp throughout — so a partial run read as a complete one. A skip is
after the block tells them a different set means *"stop and re-read it"*. never nothing: it means the `*.pdf` behaviour has no `X-Robots-Tag`, or `/api/*`
is not forwarding `CloudFront-Viewer-Address`, and the skip line says which.
⚠️ **THE SHAPE CHANGES WITH THE STATE, SO READ THE `+` LINES AND NOT THE
TOTAL.** Each item Parts 13 have already applied prints `·` when it is found
and `+` when it is staged, so in the Part 0.3 state four lines cross from one
column to the other and the totals move with them. **The `+` lines are the
check.**
**Expect** — this is the dry run, and on a distribution in the state Part 0.3 **Expect** — this is the dry run, and on a distribution in the state Part 0.3
records the change lines are: records the change lines are:
@@ -370,15 +377,19 @@ Fewer than eight changes means part of this is already done — read which lines
are prefixed `·` — but READ THE WORDS, not the bullet: `configure.mjs` uses `·` are prefixed `·` — but READ THE WORDS, not the bullet: `configure.mjs` uses `·`
for *already present* **and** for *would CREATE / would SET / would ADD*, so the for *already present* **and** for *would CREATE / would SET / would ADD*, so the
prefix alone does not say whether a line is done or still pending. **On the live distribution as at prefix alone does not say whether a line is done or still pending. **On the live distribution as at
2026-09-04 the dry run returns exactly four `+` lines — the two for section 4 2026-09-04, after the second `--apply` attempt, the dry run returns exactly
and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag three `+` lines — **one** for section 4 (the behaviour; its policy already
exists, see the incident below) and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag
`E2EUQ1WTGCTBG2`, exit 0, nothing written]`. More than eight, or a different set, `E2EUQ1WTGCTBG2`, exit 0, nothing written]`. More than eight, or a different set,
means the distribution is not in the state 0.3 recorded: stop and re-read it. means the distribution is not in the state 0.3 recorded: stop and re-read it.
🛑 **INCIDENT, 2026-09-04 — THE FIRST `--apply` FAILED ON ITS FIRST WRITE, AND 🛑 **INCIDENT — TWO `--apply` ATTEMPTS FAILED ON 2026-09-04, FOR TWO DIFFERENT
NOTHING REACHED THE DISTRIBUTION.** Section 4's clone was sent to REASONS, AND THE SECOND ONE LEFT A POLICY BEHIND.** The distribution is
`create-response-headers-policy` verbatim and the AWS CLI rejected it unchanged after both. Read both before the next attempt.
**client-side**, before the call left the machine:
**ATTEMPT 1 — nothing reached the distribution and nothing was created.**
Section 4's clone was sent to `create-response-headers-policy` verbatim and the
AWS CLI rejected it **client-side**, before the call left the machine:
``` ```
An error occurred (ParamValidation): Parameter validation failed: An error occurred (ParamValidation): Parameter validation failed:
@@ -425,8 +436,12 @@ aws cloudfront list-origin-request-policies --type custom --output json \
``` ```
An orphan is harmless and self-healing — the next run finds it by name, matches An orphan is harmless and self-healing — the next run finds it by name, matches
it and attaches it — **so do not delete it by hand.** Both returned `0` after it and attaches it — **so do not delete it by hand.**
the 2026-09-04 failure, which is why nothing needed doing that time
⚠️ **THE EXPECTED VALUES DIFFER BY WHICH FAILURE YOU ARE RECOVERING FROM.** After
**attempt 1** both returned `0` and nothing needed doing. After **attempt 2**
they return **`1` and `0`** — the response-headers policy is the orphan recorded
below, and `1` is the correct reading, not a second problem
`[verified 2026-09-04]`. `[verified 2026-09-04]`.
**Two changes came out of it.** The clone now **omits** any empty member at **Two changes came out of it.** The clone now **omits** any empty member at
@@ -449,9 +464,70 @@ otherwise be reached:
node infra/cloudfront/policy-shapes.test.mjs node infra/cloudfront/policy-shapes.test.mjs
``` ```
**Expect** `policy-shapes: 23 of 23 cases pass`, exit 0. Its first case is this **Expect** `policy-shapes: 53 of 53 cases pass`, exit 0. Its first case is this
incident verbatim — the live `SecurityHeadersConfig`, empty `ContentSecurityPolicy` incident verbatim — the live `SecurityHeadersConfig`, empty
and all `[verified 2026-09-04; 7 of 7 mutations killed]`. `ContentSecurityPolicy` and all. The same suite covers attempt 2's limit checks,
so this is the only command in this Part that proves both.
---
**ATTEMPT 2 — section 4 SUCCEEDED, section 5 FAILED, and the run left an orphan.**
With the clone fixed, `create-response-headers-policy` created
**`adr-sml-pdf-noindex` = `51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**. Section 5
then failed:
```
An error occurred (InvalidArgument) when calling the CreateOriginRequestPolicy
operation: The parameter Comment is too big
```
Its `Comment` was **182 characters** against a **128** cap. `update-distribution`
never ran, so the distribution is untouched — **but the account now holds a
response-headers policy that no behaviour references.**
🛑 **DO NOT DELETE THAT POLICY BY HAND.** This is the orphan case Part 3 and §7
predicted before it happened, and the recovery is **measured, not asserted**
`[measured 2026-09-04, dry run, exit 0, nothing written]`: the next run finds it
**by name**, matches it on every reconciled field, and stages it for attachment —
```
· response-headers policy adr-sml-pdf-noindex exists and matches the default behaviour
+ CacheBehaviors += *.pdf -> …, adr-sml-pdf-noindex (51c4e79b-d9c6-4c6f-907c-dbb0e73dd374)
```
— with the **create line gone** and the change count down from 4 to 3. **No
duplicate and no name collision.** (A collision would not be silent either: a
duplicate name returns `ResponseHeadersPolicyAlreadyExists`, which is a
different error from the `InvalidArgument` above. The script never reaches it,
because it looks the policy up by name first.)
⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` keeps its original
118-character text while the script now carries a shorter one. Deliberate:
adding `Comment` to the drift check would throw on this very policy and block
the run that attaches it.
**WHY NOTHING CAUGHT IT LOCALLY, AND THIS IS THE GENERAL LESSON.** Measured
2026-09-04: **`botocore/validate.py` checks neither `max` nor `pattern`** — its
`range_check()` reads only `min`, and the word `pattern` does not appear in the
file. And the 128 cap is not modelled as a constraint at all: on both policy
configs `Comment` is a bare `string`, and the number lives in the shape's
**`documentation` prose**. So **no client-side validation of any kind stood
between that 182-character string and the CloudFront API**, which is exactly why
the pre-flight below had to be built rather than relied upon.
**BOTH POLICY COMMENTS ARE NOW UNDER 80 CHARACTERS**, and the dry run enforces
the limits it knows about — `infra/cloudfront/policy-shapes.mjs`,
`PAYLOAD_LIMITS`, one entry per limit with the source it came from. **Every
entry with a cited AWS source is enforced by the service and by nothing local**
— see below. Two entries are stamped `[assumed]` and are not: no AWS source
states a policy **name** length, and those two constrain nothing this script
sends (our names are 19 and 26 characters). A breach **skips its section** rather than throwing, and — the part that
matters — **skips the whole section**, so a policy that is not created is never
staged for attachment. The suite named earlier in this Part
(`node infra/cloudfront/policy-shapes.test.mjs`) covers both the 182-character
`Comment` that failed here and the 118-character one that did not.
---
**One of the `·` lines carries a number worth reading**, and it is not a **One of the `·` lines carries a number worth reading**, and it is not a
warning: warning:
+195 -40
View File
@@ -48,6 +48,7 @@ import {
withoutEmptyMembers, withoutEmptyMembers,
isEmptyObject, isEmptyObject,
emptyObjectPaths, emptyObjectPaths,
limitViolations,
} from './policy-shapes.mjs'; } from './policy-shapes.mjs';
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -57,6 +58,30 @@ const flag = (name) => {
}; };
const APPLY = args.includes('--apply'); 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 DIST = flag('dist');
const API_DOMAIN = flag('api-domain'); const API_DOMAIN = flag('api-domain');
const FUNCTION_ARN = flag('function-arn'); const FUNCTION_ARN = flag('function-arn');
@@ -76,6 +101,35 @@ if (/^https?:/.test(API_DOMAIN) || API_DOMAIN.includes('/')) {
); );
process.exit(2); 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);
}
}
/* stderr is NEVER suppressed and the exit status is always read — the AWS CLI /* 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 reports an expired session, a missing permission and a typo'd id all on
@@ -324,6 +378,14 @@ if (catchAll !== -1 && catchAll < apiIndex) {
2026-09-03: all five security headers arrive on the live PDF today. */ 2026-09-03: all five security headers arrive on the live PDF today. */
const PDF_PATTERN = '*.pdf'; const PDF_PATTERN = '*.pdf';
const PDF_POLICY_NAME = 'adr-sml-pdf-noindex'; 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 XRT = { Header: 'X-Robots-Tag', Value: 'noindex', Override: true };
const defaultRhpId = cfg.DefaultCacheBehavior.ResponseHeadersPolicyId; const defaultRhpId = cfg.DefaultCacheBehavior.ResponseHeadersPolicyId;
@@ -398,12 +460,17 @@ if (!defaultRhpId) {
/* ⚠️ SKIP, NOT THROW — same rule as the missing-id case above, and it was /* ⚠️ 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; 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 an ABSENT source is section 4's problem alone and must not stop sections
1-3 from re-applying `router.js`. TWO THROWS BELOW ARE DELIBERATE 1-3 from re-applying `router.js`.
EXCEPTIONS, and they are exceptions for the same reason: DRIFT is a
divergence rather than an absence (`docs/09` Part 3 argues for it), and the ⚠️ TWO CONDITIONS BELOW STILL ABORT THE WHOLE RUN, AND THEY ARE THE ONLY
EMPTY-OBJECT throw means this script generated an invalid config — a bug in TWO: the DRIFT throw, and the throw on a `*.pdf` behaviour this script
the script, not a state of the world. Neither is something a later section cannot account for. Both are divergences rather than absences — someone
should be allowed to paper over. */ 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) { if (!srcCfg?.SecurityHeadersConfig) {
skipped.push( skipped.push(
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`, `${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`,
@@ -457,15 +524,40 @@ if (!defaultRhpId) {
section 4 never blocks sections 1-3 from re-applying `router.js`, without 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 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. */ 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); const empties = emptyObjectPaths(wanted);
if (empties.length) { /* ⚠️ ONE GUARD FOR THE WHOLE SECTION, NOT A BRANCH ROUND THE CREATE. No
skipped.push( policy means `pdfPolicyId` stays null, and the behaviour below would then
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — the generated policy config still ` + be staged carrying the placeholder string as its
`contains ${empties.length} empty object(s) (${empties.join(', ')}), and ` + ResponseHeadersPolicyId. */
`AWS rejects those on ParamValidation before the call leaves the machine. ` + const sectionFourBlockers = [
`withoutEmptyMembers should have removed them, so either it is broken or ` + ...(empties.length
`this script built one itself — see policy-shapes.mjs and its test.`, ? [
); `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 { } else {
if (existingPdfPolicy) { if (existingPdfPolicy) {
const have = existingPdfPolicy.ResponseHeadersPolicyConfig; const have = existingPdfPolicy.ResponseHeadersPolicyConfig;
@@ -504,7 +596,7 @@ if (!defaultRhpId) {
k === 'CustomHeadersConfig' k === 'CustomHeadersConfig'
? have.CustomHeadersConfig?.Items ? have.CustomHeadersConfig?.Items
: have[k], : have[k],
)}\n default : ${norm( )}\n wanted : ${norm(
k === 'CustomHeadersConfig' k === 'CustomHeadersConfig'
? wanted.CustomHeadersConfig.Items ? wanted.CustomHeadersConfig.Items
: wanted[k], : wanted[k],
@@ -525,7 +617,8 @@ if (!defaultRhpId) {
); );
} else if (!APPLY) { } else if (!APPLY) {
console.log( console.log(
`· would CREATE response-headers policy ${PDF_POLICY_NAME}`, `· would CREATE response-headers policy ${PDF_POLICY_NAME}` +
` (payload within every limit in PAYLOAD_LIMITS)`,
); );
changes.push( changes.push(
`create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`, `create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`,
@@ -535,12 +628,10 @@ if (!defaultRhpId) {
'cloudfront', 'cloudfront',
'create-response-headers-policy', 'create-response-headers-policy',
'--response-headers-policy-config', '--response-headers-policy-config',
JSON.stringify({ /* The object `limitViolations` inspected, not a second literal built
Name: PDF_POLICY_NAME, here. Section 5 already did this; two literals that agree today are
Comment: how a pre-flight stops covering the payload. */
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.', JSON.stringify(pdfPolicyConfig),
...wanted,
}),
'--output', '--output',
'json', 'json',
]); ]);
@@ -634,7 +725,10 @@ if (!defaultRhpId) {
? ` (${pdfPolicyId})` ? ` (${pdfPolicyId})`
: ' (policy id created in the same --apply pass)'), : ' (policy id created in the same --apply pass)'),
); );
if (!APPLY && !pdfPolicyId) { /* ⚠️ `!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( console.log(
`· would ADD cache behaviour ${PDF_PATTERN}:\n` + `· would ADD cache behaviour ${PDF_PATTERN}:\n` +
JSON.stringify(behaviour, null, 2) JSON.stringify(behaviour, null, 2)
@@ -729,6 +823,13 @@ if (!defaultRhpId) {
procedure for that. Forwarding a header is infrastructure. Storing it is a procedure for that. Forwarding a header is infrastructure. Storing it is a
privacy-policy edit. */ privacy-policy edit. */
const ORP_NAME = 'adr-sml-api-viewer-address'; 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 /* Sorted, because the drift check below compares this list to what CloudFront
returns and an ordering difference would read as a drift. */ returns and an ordering difference would read as a drift. */
const ORP_HEADERS = [ const ORP_HEADERS = [
@@ -801,10 +902,25 @@ if (!apiBehaviour) {
); );
} else { } else {
const reads = handlerHeaderReads(); const reads = handlerHeaderReads();
if (reads === null) { /* ⚠️ THE PROBE CAN BREAK, AND A BROKEN PROBE READS AS A CLEAN PASS. `reads`
skipped.push( is `null` when the file is absent and `[]` when the regex stops matching —
`${PATH_PATTERN} / ${ORP_NAME} — backend/intake/handler.mjs is not in this checkout, so the whitelist could not be checked against the handler's own reads`, 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 { } else {
const lower = ORP_HEADERS.map((h) => h.toLowerCase()); const lower = ORP_HEADERS.map((h) => h.toLowerCase());
const missing = reads.filter((h) => !lower.includes(h)); const missing = reads.filter((h) => !lower.includes(h));
@@ -823,7 +939,6 @@ if (!apiBehaviour) {
console.log( console.log(
`· whitelist covers all ${reads.length} headers the handler reads (${reads.join(', ')})`, `· whitelist covers all ${reads.length} headers the handler reads (${reads.join(', ')})`,
); );
}
const existingOrp = findApiOriginRequestPolicy(); const existingOrp = findApiOriginRequestPolicy();
let orpId = existingOrp?.Id ?? null; let orpId = existingOrp?.Id ?? null;
@@ -837,6 +952,17 @@ if (!apiBehaviour) {
QueryStringsConfig: { QueryStringBehavior: '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) { if (existingOrp) {
const have = existingOrp.OriginRequestPolicyConfig; const have = existingOrp.OriginRequestPolicyConfig;
const norm = (o) => JSON.stringify(o ?? null); const norm = (o) => JSON.stringify(o ?? null);
@@ -844,7 +970,9 @@ if (!apiBehaviour) {
HeadersConfig object: CloudFront echoes `Quantity` back and a list that HeadersConfig object: CloudFront echoes `Quantity` back and a list that
differs only in order is the same forwarding rule. A drift report that differs only in order is the same forwarding rule. A drift report that
fires on ordering is a drift report nobody reads twice. */ fires on ordering is a drift report nobody reads twice. */
const haveHeaders = [...(have.HeadersConfig?.Headers?.Items ?? [])].sort(); const haveHeaders = [
...(have.HeadersConfig?.Headers?.Items ?? []),
].sort();
const drift = []; const drift = [];
if (have.HeadersConfig?.HeaderBehavior !== 'whitelist') if (have.HeadersConfig?.HeaderBehavior !== 'whitelist')
drift.push([ drift.push([
@@ -868,7 +996,9 @@ if (!apiBehaviour) {
`${drift.length} field(s). ${PATH_PATTERN} is the intake form's path, ` + `${drift.length} field(s). ${PATH_PATTERN} is the intake form's path, ` +
`so read which way before repairing:\n` + `so read which way before repairing:\n` +
drift drift
.map(([k, a, b]) => ` ${k}\n live : ${a}\n wanted : ${b}`) .map(
([k, a, b]) => ` ${k}\n live : ${a}\n wanted : ${b}`,
)
.join('\n') + .join('\n') +
`\nReconcile with update-origin-request-policy (it needs the policy's ` + `\nReconcile with update-origin-request-policy (it needs the policy's ` +
`own ETag), then re-run.` + `own ETag), then re-run.` +
@@ -880,8 +1010,16 @@ if (!apiBehaviour) {
); );
} }
console.log(`· origin request policy ${ORP_NAME} exists and matches`); 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) { } else if (!APPLY) {
console.log(`· would CREATE origin request policy ${ORP_NAME}`); console.log(
`· would CREATE origin request policy ${ORP_NAME}` +
` (payload within every limit in PAYLOAD_LIMITS)`,
);
changes.push( changes.push(
`create origin request policy ${ORP_NAME} (whitelist: ${ORP_HEADERS.join(', ')}; cookies all; query strings all)`, `create origin request policy ${ORP_NAME} (whitelist: ${ORP_HEADERS.join(', ')}; cookies all; query strings all)`,
); );
@@ -890,12 +1028,7 @@ if (!apiBehaviour) {
'cloudfront', 'cloudfront',
'create-origin-request-policy', 'create-origin-request-policy',
'--origin-request-policy-config', '--origin-request-policy-config',
JSON.stringify({ JSON.stringify(orpPolicyConfig),
Name: ORP_NAME,
Comment:
'Forwards CloudFront-Viewer-Address plus the four headers the intake handler reads. Replaces Managed-AllViewerExceptHostHeader on /api/*. See infra/cloudfront/configure.mjs section 5.',
...wantedOrp,
}),
'--output', '--output',
'json', 'json',
]); ]);
@@ -908,7 +1041,18 @@ if (!apiBehaviour) {
changes.push(`created origin request policy ${ORP_NAME} (${orpId})`); changes.push(`created origin request policy ${ORP_NAME} (${orpId})`);
} }
if (orpId && apiBehaviour.OriginRequestPolicyId === 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}`); console.log(`· ${PATH_PATTERN} already uses ${ORP_NAME}`);
} else if (!APPLY) { } else if (!APPLY) {
console.log( console.log(
@@ -950,6 +1094,7 @@ if (!apiBehaviour) {
); );
} }
} }
}
console.log(''); console.log('');
/* Skips print under their own heading and are NOT counted as changes — see the /* Skips print under their own heading and are NOT counted as changes — see the
@@ -966,13 +1111,22 @@ if (skipped.length) {
console.log(' CloudFront-Viewer-Address — the skip above says which.'); console.log(' CloudFront-Viewer-Address — the skip above says which.');
console.log(''); 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) { if (changes.length === 0) {
console.log( console.log(
skipped.length skipped.length
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all five.' ? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all five.'
: 'NOTHING TO CHANGE — the distribution already carries all five.', : 'NOTHING TO CHANGE — the distribution already carries all five.',
); );
process.exit(0); process.exit(exitCode);
} }
console.log( console.log(
`${changes.length} change(s) to distribution ${DIST} (ETag ${etag}):`, `${changes.length} change(s) to distribution ${DIST} (ETag ${etag}):`,
@@ -982,7 +1136,7 @@ console.log('');
if (!APPLY) { if (!APPLY) {
console.log('DRY RUN — nothing was sent. Re-run with --apply to write it.'); console.log('DRY RUN — nothing was sent. Re-run with --apply to write it.');
process.exit(0); process.exit(exitCode);
} }
const res = aws([ const res = aws([
@@ -1003,3 +1157,4 @@ console.log(
"runbook's verification block:\n" + "runbook's verification block:\n" +
` aws cloudfront wait distribution-deployed --id ${DIST}`, ` aws cloudfront wait distribution-deployed --id ${DIST}`,
); );
process.exit(exitCode);
+241
View File
@@ -75,3 +75,244 @@ export function emptyObjectPaths(value, path = '') {
} }
return []; 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;
}
+321
View File
@@ -12,6 +12,8 @@ import {
withoutEmptyMembers, withoutEmptyMembers,
emptyObjectPaths, emptyObjectPaths,
isEmptyObject, isEmptyObject,
limitViolations,
PAYLOAD_LIMITS,
} from './policy-shapes.mjs'; } from './policy-shapes.mjs';
let pass = 0; let pass = 0;
@@ -195,6 +197,325 @@ t(
[], [],
); );
/* ---- PAYLOAD_LIMITS: the 2026-09-04 second failure -----------------------
InvalidArgument, "The parameter Comment is too big", from
create-origin-request-policy. The model types Comment as a bare `string`, so
ParamValidation could not see it and the dry run was the only place it could
have been caught. */
const ORP = (comment) => ({
Name: 'adr-sml-api-viewer-address',
Comment: comment,
HeadersConfig: {
HeaderBehavior: 'whitelist',
Headers: { Quantity: 1, Items: ['Origin'] },
},
});
t(
'the 182-character Comment that failed is reported',
limitViolations('origin-request-policy', ORP('x'.repeat(182))).map((v) => [
v.path,
v.actual,
v.limit,
]),
[['Comment', 182, 128]],
);
t(
'the shipped Comment passes',
limitViolations(
'origin-request-policy',
ORP(
'Forwards CloudFront-Viewer-Address on /api/*. See configure.mjs section 5.',
),
),
[],
);
t(
'128 exactly is allowed — the cap is inclusive',
limitViolations('origin-request-policy', ORP('x'.repeat(128))),
[],
);
t(
'129 is not',
limitViolations('origin-request-policy', ORP('x'.repeat(129))).length,
1,
);
t(
'the 118-character Comment AWS accepted on 2026-09-04 passes',
limitViolations('response-headers-policy', {
Name: 'adr-sml-pdf-noindex',
Comment:
'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.',
}),
[],
);
/* Section 4's payload needs its OWN over-cap cases: asserting only that the
accepted 118 passes leaves the cap free to be wrong in the loose direction,
which a mutation raising it to 1280 proved by surviving. */
t(
'a 129-character response-headers Comment is caught',
limitViolations('response-headers-policy', {
Name: 'adr-sml-pdf-noindex',
Comment: 'x'.repeat(129),
}).map((v) => [v.path, v.actual, v.limit]),
[['Comment', 129, 128]],
);
t(
'and 128 exactly is allowed',
limitViolations('response-headers-policy', {
Name: 'adr-sml-pdf-noindex',
Comment: 'x'.repeat(128),
}),
[],
);
t(
'an over-long policy Name is caught on both policy kinds',
[
limitViolations('response-headers-policy', { Name: 'n'.repeat(129) })
.length,
limitViolations('origin-request-policy', { Name: 'n'.repeat(129) }).length,
],
[1, 1],
);
t(
'and both shipped names pass',
[
limitViolations('response-headers-policy', { Name: 'adr-sml-pdf-noindex' })
.length,
limitViolations('origin-request-policy', {
Name: 'adr-sml-api-viewer-address',
}).length,
],
[0, 0],
);
/* ---- the function ARN, the one limit the service model does give us ------ */
const GOOD_ARN = 'arn:aws:cloudfront::327082975128:function/adr-sml-router';
t(
'a correctly derived function ARN passes',
limitViolations('function-association', { FunctionARN: GOOD_ARN }),
[],
);
t(
'the tab-doubled ARN that `list-functions --output text` returns breaks both rules',
limitViolations('function-association', {
FunctionARN: `${GOOD_ARN}\t${GOOD_ARN}`,
})
.map((v) => v.rule)
.sort(),
['maxLength', 'pattern'],
);
t(
'a Lambda@Edge ARN is not a CloudFront function ARN',
limitViolations('function-association', {
FunctionARN: 'arn:aws:lambda:us-east-1:327082975128:function:edge',
}).some((v) => v.rule === 'pattern'),
true,
);
/* ---- the aggregate rules, both documented on the CloudFront quotas page -- */
const HDRS = (items) => ({
Name: 'adr-sml-api-viewer-address',
Comment: 'c',
HeadersConfig: {
HeaderBehavior: 'whitelist',
Headers: { Quantity: items.length, Items: items },
},
});
const SHIPPED_HEADERS = [
'CloudFront-Viewer-Address',
'Content-Type',
'Origin',
'Referer',
'User-Agent',
];
t(
'the five headers we actually whitelist pass every rule',
limitViolations('origin-request-policy', HDRS(SHIPPED_HEADERS)),
[],
);
t(
'an 11th whitelisted header breaches "Headers per origin request policy | 10"',
limitViolations(
'origin-request-policy',
HDRS(Array.from({ length: 11 }, (_, i) => `X-H${i}`)),
).map((v) => [v.rule, v.actual, v.limit]),
[['maxCount', 11, 10]],
);
t(
'ten is allowed',
limitViolations(
'origin-request-policy',
HDRS(Array.from({ length: 10 }, (_, i) => `X-H${i}`)),
),
[],
);
t(
'header names totalling over 1024 breach the combined-length quota',
limitViolations(
'origin-request-policy',
HDRS(Array.from({ length: 9 }, () => 'X'.repeat(120))),
)
.map((v) => v.rule)
.sort(),
['maxCombinedLength'],
);
t(
'an 11th custom response header breaches its own count quota',
limitViolations('response-headers-policy', {
Name: 'n',
CustomHeadersConfig: {
Quantity: 11,
Items: Array.from({ length: 11 }, (_, i) => ({
Header: `X-${i}`,
Value: 'v',
})),
},
}).map((v) => v.rule),
['maxCount'],
);
t(
'the single X-Robots-Tag header we add passes',
limitViolations('response-headers-policy', {
Name: 'adr-sml-pdf-noindex',
Comment:
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.',
CustomHeadersConfig: {
Quantity: 1,
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
},
}),
[],
);
t(
'names and values combined over 10,240 are caught',
limitViolations('response-headers-policy', {
Name: 'n',
CustomHeadersConfig: {
Quantity: 8,
Items: Array.from({ length: 8 }, (_, i) => ({
Header: `X-${i}`,
Value: 'v'.repeat(1500),
})),
},
}).some((v) => v.rule === 'maxCombinedLength'),
true,
);
/* ---- the walker -------------------------------------------------------- */
t(
'[] resolves every element and the violation names the index',
limitViolations('response-headers-policy', {
Name: 'n',
CustomHeadersConfig: {
Quantity: 2,
Items: [
{ Header: 'X-Robots-Tag', Value: 'noindex' },
{ Header: 'X'.repeat(300), Value: 'v' },
],
},
}).map((v) => v.path),
['CustomHeadersConfig.Items[1].Header'],
);
t(
'an absent field is not a violation',
limitViolations('response-headers-policy', { Name: 'n' }),
[],
);
t(
'a non-string value is skipped rather than crashing',
limitViolations('response-headers-policy', { Name: 'n', Comment: 12345 }),
[],
);
t(
'a null along the path is skipped',
limitViolations('response-headers-policy', {
Name: 'n',
CustomHeadersConfig: null,
}),
[],
);
t(
'an unknown payload kind throws rather than passing silently',
(() => {
try {
limitViolations('nope', {});
return 'no throw';
} catch (e) {
return e.message.includes('nope');
}
})(),
true,
);
t(
'every limit entry carries a source',
Object.values(PAYLOAD_LIMITS)
.flat()
.every((l) => typeof l.source === 'string' && l.source.length > 0),
true,
);
t(
'every entry addresses exactly one of path / paths',
Object.values(PAYLOAD_LIMITS)
.flat()
.every((l) => (l.path === undefined) !== (l.paths === undefined)),
true,
);
/* ---- the CLONED values, which change without this file being touched.
`docs/05` specifies a Content-Security-Policy that would land on the default
behaviour's policy and be copied straight into ours; the API declares
TooLongCSPInResponseHeadersPolicy for exactly that. */
const withCsp = (csp) => ({
Name: 'adr-sml-pdf-noindex',
Comment: 'c',
SecurityHeadersConfig: {
ContentTypeOptions: { Override: true },
ContentSecurityPolicy: { Override: false, ContentSecurityPolicy: csp },
},
});
t(
'a cloned CSP over 1,783 characters is caught before the create',
limitViolations('response-headers-policy', withCsp('x'.repeat(1784))).map(
(v) => [v.path, v.actual, v.limit],
),
[
[
'SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy',
1784,
1783,
],
],
);
t(
'1,783 exactly is allowed',
limitViolations('response-headers-policy', withCsp('x'.repeat(1783))),
[],
);
t(
'a realistic CSP passes',
limitViolations(
'response-headers-policy',
withCsp("default-src 'self'; img-src 'self' data:; style-src 'self'"),
),
[],
);
t(
'the live source policy, which defines no CSP at all, passes whole',
limitViolations('response-headers-policy', {
Name: 'adr-sml-pdf-noindex',
Comment:
'X-Robots-Tag: noindex on *.pdf, cloned headers. See configure.mjs section 4.',
SecurityHeadersConfig: stripped,
CustomHeadersConfig: {
Quantity: 1,
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
},
}),
[],
);
if (failures.length) { if (failures.length) {
console.error( console.error(
`policy-shapes: ${failures.length} FAILED\n - ${failures.join('\n - ')}`, `policy-shapes: ${failures.length} FAILED\n - ${failures.join('\n - ')}`,