From bbe535d15833d3c34246f78dc2789c969be4bd3b Mon Sep 17 00:00:00 2001 From: Pouya Lajevardi Date: Fri, 4 Sep 2026 12:59:04 -0400 Subject: [PATCH] fix: omit AWS's empty-object placeholders from the cloned PDF policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `configure.mjs --apply` failed on its first write, 2026-09-04, and nothing reached the distribution. `get-response-headers-policy` returns `"ContentSecurityPolicy": {}` for a member the source does not define, and sending that back fails `create-response-headers-policy` on ParamValidation before the call leaves the machine — a config AWS hands back is not necessarily a config AWS will accept. Of the 16 structures reachable from `ResponseHeadersPolicyConfig` in the CLI's service model, 15 declare a required field, so `{}` is illegal there and can only be the placeholder; the one exception is `SecurityHeadersConfig` itself, which section 4 already skips on when empty. The strip is therefore recursive. The dry run now asserts the generated config carries no empty object, and does so as a section-4 SKIP rather than a throw — section 4 must never block sections 1-3 from re-applying `router.js`. The two functions move to `policy-shapes.mjs` with a 23-case test (7 of 7 mutations killed), because `configure.mjs` reads argv and calls AWS at import time and the runbook was otherwise claiming a proof nobody could re-run. Also: the handler was redeployed 2026-09-04 via docs/09 §5.5. Re-read against production — the two bundled SDK clients moved 3.1125.0 -> 3.1126.0 with no file in this repository changing, which is what §7's own row predicted. §12 gains R22, because that row named itself as the reminder covering them while no such reminder existed. docs/05, docs/06 and docs/09 §5.5 each held their own stale copy of the deployed commit; all three now cite §7. Reviewed twice by adversarial-reviewer: 7 findings, then 8, of which five were defects in the first round's repairs. All 15 fixed. Nothing was applied to the distribution and nothing was deployed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5 --- AGENTS.md | 158 ++++++++- docs/05-backend-spec.md | 7 +- docs/06-deployment.md | 7 +- docs/09-cutover-runbook.md | 124 +++++++- infra/cloudfront/configure.mjs | 407 ++++++++++++++---------- infra/cloudfront/policy-shapes.mjs | 77 +++++ infra/cloudfront/policy-shapes.test.mjs | 204 ++++++++++++ 7 files changed, 802 insertions(+), 182 deletions(-) create mode 100644 infra/cloudfront/policy-shapes.mjs create mode 100644 infra/cloudfront/policy-shapes.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 9472bf1..567a48f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -725,11 +725,11 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec | AWS account | `327082975128` `[verified 2026-08-26 — inventory]` | | 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]` | -| 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 1–3 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. 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 1–3 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 | | 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 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,307,021 bytes**, last modified **2026-09-02T18:59:06Z** `[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`, `node_modules/` and `package.json`, and **both source files are byte-identical to commit `02739ad`** — sha1 `ab94d506…` and `18b44c74…` `[verified 2026-09-04]`. ⚠️ **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 — the two bundled SDK pins** | **`@aws-sdk/client-dynamodb@3.1125.0`** and **`@aws-sdk/client-sesv2@3.1125.0`**, read out of the deployed zip `[verified 2026-09-04]`. ⚠️ **`docs/09` §5.5 REQUIRES THESE TO BE RECORDED HERE THE MOMENT THAT PATH IS TAKEN — *"add both packages to §7… they become pins this project maintains, and R11 covers them from that moment"*. ⚠️ **R11's OWN INSTRUCTION IS SCOPED TO `package.json`** — *"run `npm view version` across every pin in `package.json`"* — and these two are deliberately NOT in it, so §5.5's sentence extends R11 past its written scope. Recorded rather than quietly relied on: **the reminder that actually covers them is this row.** The path was taken on 2026-09-02 and they reached no document until 2026-09-04**, so two production dependencies sat outside dependency-currency review for two days. They are **not** in `package.json` and never will be: §5.5 resolves them from the registry at install time, so a redeploy takes whatever is current — `npm view` returns **3.1126.0** for both `[verified 2026-09-04 — R11]`, i.e. the next handler deploy moves them one patch without anyone choosing to. That is the trade §5.5 makes deliberately; the number here is what is RUNNING, not a pin that constrains it | +| 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 — the two bundled SDK pins** | **`@aws-sdk/client-dynamodb@3.1126.0`** and **`@aws-sdk/client-sesv2@3.1126.0`**, read out of the deployed zip `[verified 2026-09-04, after the 15:53Z redeploy]`. 🛑 **THEY MOVED FROM `3.1125.0` ON THE SAME DAY THIS ROW FIRST RECORDED THEM, AND NOBODY CHOSE IT.** The row's own closing sentence predicted precisely this — *"the next handler deploy moves them one patch without anyone choosing to"* — and the §5.5 redeploy did it within hours. **The prediction is now a measurement, which is the argument FOR this row rather than against it:** two production dependencies changed version, no file in this repository changed, and nothing but this row would have said so. ⚠️ **`docs/09` §5.5 REQUIRES THESE TO BE RECORDED HERE THE MOMENT THAT PATH IS TAKEN — *"add both packages to §7… they become pins this project maintains, and R11 covers them from that moment"*. ⚠️ **R11's OWN INSTRUCTION IS SCOPED TO `package.json`** — *"run `npm view version` across every pin in `package.json`"* — and these two are deliberately NOT in it, so §5.5's sentence extends R11 past its written scope. Recorded rather than quietly relied on: **the reminder that covers them is §12 R22**, added 2026-09-04 — this row held that claim for itself while no such reminder existed, which is the Q22 shape (a control that is documented and absent). §7 holds the values; R22 holds the obligation to re-read them. The path was taken on 2026-09-02 and they reached no document until 2026-09-04**, so two production dependencies sat outside dependency-currency review for two days. They are **not** in `package.json` and never will be: §5.5 resolves them from the registry at install time, so a redeploy takes whatever is current — `npm view` returns **3.1126.0** for both `[verified 2026-09-04 — R11]`, which is exactly what the bundle now carries, **because** the redeploy resolved them afresh. Registry and running agree today; they diverge again at the next publish upstream, with no diff anywhere to show it. That is the trade §5.5 makes deliberately; the number here is what is RUNNING, not a pin that constrains it | | Intake table | `adr-intake-submissions` (DynamoDB, ca-central-1) `[verified 2026-08-26]`. ⚠️ **KEY SCHEMA: PARTITION KEY `submissionId` (S), NO SORT KEY** ``[verified 2026-09-01 — `aws dynamodb describe-table`]``. **This contradicted `docs/05`, which specified `pk`/`sk`, and `backend/intake/handler.mjs` was written to the spec** — a `PutItem` missing the key attribute fails the whole write with `ValidationException`, the handler catches it and returns the failure page, so **every submission would have been lost while looking like a browser problem**. A DynamoDB key schema cannot be altered after creation; the handler was changed to the table on 2026-09-01 and `docs/05` §Storage carries the correction and the declined alternative. PITR **`ENABLED`**, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`. Encryption at rest uses the **AWS-owned key — there is no customer-managed KMS key** `[verified 2026-09-01 — describe-table returns no SSEDescription]`, which `/legal/privacy/` does not claim, so nothing published depends on it. **4 items predate this repo**, written by the hand-built handler, which writes **no `ttl`** — so they never expire; `docs/06` carries that as Pouya's call. **TTL IS `ENABLED`, `AttributeName: ttl`** ``[verified 2026-08-31 — Pouya ran `describe-time-to-live` and read `TimeToLiveStatus: ENABLED`]``. The handler side matches: `backend/intake/handler.mjs` writes `ttl` as a Number in **epoch seconds** at **24 months** (`RETENTION_MONTHS = 24`, added to `getUTCMonth()`), which is `docs/05` §Retention and the `ttl` row of its item table `[verified 2026-08-31 — read from the handler, not recalled]`. ⚠️ **IT WAS `DISABLED` AT FIRST VERIFICATION EARLIER THE SAME DAY, AND THAT IS RECORDED RATHER THAN OVERWRITTEN.** Pouya ran `describe-time-to-live` on **2026-08-31** and it returned `DISABLED`; he enabled it on **2026-08-31** and re-read `ENABLED` the same day. `/legal/privacy/` has stated since build step 10 that a record is *"deleted automatically by the database rather than by someone remembering to do it"* after 24 months, so **that promise was unbacked from the day it was written until the day it was enabled** — the handler wrote the attribute and nothing on the table consumed it. This is the Q22 shape on a public privacy commitment rather than on a deploy control: a documented mechanism that did not exist. ⚠️ **`ENABLED` PROVES THE SETTING, NOT THE BEHAVIOUR, AND THE BEHAVIOUR IS STILL UNPROVEN — §9 Q60 STAYS OPEN.** No record has been written with a near-future `ttl` and watched to disappear. `docs/06`'s cutover checklist carries that test as a blocking item, it is not ticked by reading this row or the handler code, and §12 R19 keeps it surfacing until a deletion has actually been observed | | **Intake table — who can read it** | 🛑 **TWO IAM IDENTITIES — AND THAT IS A COUNT OF IDENTITIES, NOT OF PEOPLE. `/legal/privacy/` NO LONGER PUBLISHES A HUMAN NUMBER (Q63, ruled 2026-09-02).** ⚠️ **THE DISTINCTION IS THE ROW'S MOST IMPORTANT CONTENT, because this register supplied the false one.** The enumeration below is exhaustive over identities and every read path terminates at `user/pouya` or `user/lars` — and the page then rendered that as *"Two people can"*. **Pouya's attestation, 2026-09-02: *"two people is an exaggeration… a handful is accurate"*.** A simulation cannot see how many humans reach a credential, so the identity count is a **lower** bound on people and was published as an exact one. The page now says *"The record in the table: me, and the small number of people who administer the account it sits in with me"*, and **no numeric human headcount may ship**. ⚠️ **THAT SENTENCE HAS BEEN RE-QUOTED HERE THREE TIMES IN ONE DAY AND WAS WRONG TWICE — QUOTE IT FROM `dist/`, NEVER FROM A RULING OR FROM THIS ROW'S PREVIOUS VALUE.** ⚠️ **AND AS OF POUYA'S SECOND RULING THAT DAY, THIS ROW IS THE ONLY HOME FOR MOST OF WHAT FOLLOWS.** **Two facts below still ship** and must be kept true on the page as well as here: the Lambda role's **add-only** access (§Who can see it, paragraph 2) and the **shared account** (§Where it is stored). Everything else is this row's alone. *"The page stays generic. It over-explains technical mechanics that belong in the evidence file, not in front of an inquirer."* Deleted from `/legal/privacy/` §Who can see it: the measurement paragraph, the root-credential sentence, the single-sign-on and federated-login enumeration, the resource-policy clause, the *"company that runs a database"* aside, the deploy-credential sentence and the three-copies summary. ⚠️ **THE SHARED-ACCOUNT CLAUSE WAS CUT WITH THEM AND THEN RESTORED — to §Where it is stored, where it belongs.** It is a storage disclosure rather than mechanics, the ruling did not name it, and without it no page told a reader their intake sits in an account that also runs unrelated systems (`adversarial-reviewer`, round 1). **These lists must stay identical — there were four of them and they named four different sets.** **Everything below is unchanged, unretracted and still measured** — and all of it except the two facts named above has stopped appearing anywhere a reader can see it, which **raises** this row's stakes rather than lowering them: a register that nothing public contradicts is a register nobody re-reads. **Nothing here may be restored to the page**; the section comment in `src/pages/legal/privacy.astro` carries that bar. **ROOT: held by Pouya `[verified 2026-09-02 — Pouya, Q63(c)]` — RECORDED HERE, PUBLISHED NOWHERE.** The page stated it for part of 2026-09-02 and the sentence was deleted by the mechanics ruling; §9 **Q64** — *does anyone else hold it* — is closed as **MOOT rather than answered**, so ⚠️ *held by* is still not *held only by*, and **nothing about root custody may be published without asking him again** — not an IAM principal, cannot be simulated, no policy constrains it; `AccountAccessKeysPresent: 0` and `AccountMFAEnabled: 1` `[verified 2026-09-02]`. The identity facts follow, and they are unchanged and still exhaustive. `user/pouya` and `user/lars`, both via group **`admins`** carrying `AdministratorAccess`. **Four roles can also read it** — two `cdk-hnb659fds-cfn-exec-role-*` (all seven actions; trust `cloudformation.amazonaws.com` only) and two `cdk-hnb659fds-lookup-role-*` (the four read actions; trust the account root, and `sts:AssumeRole` is **allowed only for those same two users**). **`adr-intake-lambda-role` holds `PutItem` ONLY** — implicitDeny on `GetItem`/`Query`/`Scan`/`BatchGetItem`/`UpdateItem`/`DeleteItem`. **`adr-sml-deploy` is implicitDeny on all seven.** The CDK/CloudFormation escalation path is implicitDeny for all three deploy users. No SAML, OIDC or Identity Center principal exists (0/0/0) — **and the account is not in an AWS Organization (`AWSOrganizationsNotInUseException`), which is what makes that Identity Center zero conclusive rather than merely local** `[verified 2026-09-02]`. **No resource-based policy on the table: `dynamodb get-resource-policy` returns `PolicyNotFoundException`** `[verified 2026-09-02]` — a command, not an inference; a resource policy is invisible to `describe-table` and grants from the opposite side to every simulation here, so nothing else in the enumeration could have seen one. Root holds **no access keys**, MFA on. ``[verified 2026-09-02 — `get-resource-policy` on the table, `organizations describe-organization`, 5 users x 7 actions, **all 33** roles x 7 actions — 26 non-service-linked and, added 2026-09-02, the 7 service-linked ones every earlier sweep had excluded by `grep -v '^AWSServiceRole'`, all implicitDeny, 4 trust policies, 5 users x 6 CDK-path actions, all in `docs/reference/intake-table-access-verification.md` with the counts asserted per call]``. ⚠️ **THE ORIGINAL 2026-09-01 VERIFICATION WAS NOT ENOUGH FOR THE SENTENCE IT BACKED**: it screened roles with `list-attached-role-policies` alone, so it never saw that **23 of 26 roles carry inline policies** and that the two `lookup` roles can read the table. Four roles can, not two. The conclusion held; the reasoning did not. **THE PAGE GOES FALSE IF THIS CHANGES AND NOTHING IN AWS WILL SAY SO — §12 R21 is the trigger.** *(Less of the page than before: the mechanics cut of 2026-09-02 took four of R21's five claims off `/legal/privacy/`, leaving the administrators sentence and the mailbox. **The trigger did not weaken with them** — this row still asserts everything below, and `docs/06` still instructs an operator to re-run the verification before cutover.)* | | **`info@smlcompany.ca` — who reads it** | **A DELEGATED MAILBOX: Pouya AND administrative staff `[verified 2026-09-02 — Pouya, Q63(b)]`.** D18 sends the intake notification here, so this is the access list for the **second** copy of every submission — including the opposing parties and their counsel, which is the most sensitive thing the form collects. `/legal/privacy/` states it in terms (*"read by me and by administrative staff"*), having previously said *"anyone who can reach that mailbox"* — true either way, and a lower standard than the measured answer given one paragraph earlier for the table. ⚠️ **THIS IS AN ATTESTATION, NOT A MEASUREMENT, and it is the only fact behind a `/legal/privacy/` sentence that is.** Nothing in this repository or in AWS can check it: the mailbox is on Google Workspace (see the mail-hosting row) and this repo holds no Workspace credential. **It goes stale the way the AWS enumeration does and by the same mechanism — nobody is told when a delegation changes — so §12 R21's trigger covers it too.** | @@ -949,6 +949,7 @@ never being raised again. | R19 | ⚠️ **DYNAMODB TTL BACKS A PUBLISHED PRIVACY PROMISE AND `/legal/privacy/` ASSERTS THE MECHANISM, NOT JUST THE PERIOD.** **§7 records the status and its stamp; this row deliberately does not restate it** — one place for a service status, or the copy that goes stale is the one nobody re-reads. **THE TRIGGER, and its two halves are not interchangeable: re-run `describe-time-to-live` and confirm `ENABLED`, THEN write a record with a near-future `ttl` and confirm it actually disappears.** `ENABLED` proves the setting; only the test record proves the behaviour. Writing the attribute proves neither — the handler's side is verified and is not what this row is about. Both halves are on `docs/06`'s cutover checklist and the question is §9 Q60. Re-stamp §7 the same day, **and when you do, sweep for the copies: this fact reached five files outside §7 in one change set and had to be pulled back.** Close this row only when the test record has been observed to vanish | 2026-08-31 | **This is R9's exact shape at higher stakes.** R9 exists because the SES alarms are configured and notify nobody until one subscription is confirmed — a control that reads as covered and is not. Here the control backs a **statement to the public on a privacy policy**, which is the one class of claim this project treats as unrecoverable, and the failure is silent in both directions: nothing in the repo, the build or AWS reports that records are accumulating forever. A cutover checklist fires once; §12 is read aloud every session | | R20 | ⚠️ **THE SEVENTH NAV ITEM ARMS TWO MEASURED HEADER DEFECTS, AND ITS TRIGGER IS A CONTENT EVENT RATHER THAN A DATE.** `SiteHeader` computes `showInsights` from the collection — Insights joins the masthead **automatically at two published articles** — so nothing in the build, the specs or a person's memory stands between publishing article #2 and arming both of these. With seven items **and fallback font metrics** (what a reader on `docs/04`'s Slow 4G profile sees during the `font-display: swap` window, at the DEFAULT text size, no reader setting involved) the header measures **141 px across a contiguous 1056–1091 px band** instead of 81 px: **(a)** it then collapses **60 px** when Geist swaps in, on all 22 pages, against the CLS < 0.05 budget; and **(b)** 141 px exceeds the 97 px `scroll-padding-top`, so "Skip to content" lands with **44 px of `#main` behind the sticky header** — and (b) is **new as of 2026-09-01**, the previous build's 86.97 px stayed under 97 px and covered 0. ⚠️ **HARDENED FROM A TRIGGER INTO A GATE — Pouya's ruling, 2026-09-01: NO SEVENTH NAV ITEM SHIPS UNTIL THE FALLBACK-METRICS DEFECT IS FIXED.** So fixing it is a **prerequisite of publishing the second Insights article**, not a follow-up to it, and *"font metric overrides on the fallback face or equivalent — to be designed then, not now"*. ⚠️ **AND THE GATE IS A BUILD FAILURE, NOT A CROSS-REFERENCE — corrected 2026-09-01 by `adversarial-reviewer`, round 2, in the same session that wrote the weaker version.** It was first implemented as three prose pointers, justified with the claim that the comment on `showInsights` in `SiteHeader.astro` is *"the only one of the three a person editing an article's front matter is likely to be reading"*. **That was backwards**: someone editing `src/content/insights/*.mdx` has no reason to open a header component. And it did not gate: with two articles flipped to `draft: false`, `npm run build` succeeded and `check`, `check:claims`, `og:proof`, `check:intake` and `lint` all exited 0 while both defects shipped. **`SiteHeader.astro` now THROWS when `published.length >= 2`**, with the measurements and the instruction in the message; it fires on both deploy paths, on the machine of whoever publishes. **Proven, not assumed:** two articles were temporarily published, `npm run build` exited **1** naming R20, and the files were restored and the restoration verified by `git diff --exit-code` plus an unchanged `dist` digest. The prose pointers remain — `docs/06`'s `/insights/` state item and its seventh-nav-item item under **Technical**, the latter deliberately unticked and marked NOT a cutover blocker — but they document the gate rather than being it. This project already knew the remedy: `content.config.ts` refuses `draft: false` without `reviewedByPouya: true` rather than trusting a comment, and `check:intake`/`og:proof` exist because a duplicated fact needs a mechanism. *Previous wording follows, and it was too weak: it asked for a re-measurement and a ruling at publication time, which leaves the defect shipping if the person publishing does not read this file.* **THE TRIGGER: before publishing the second Insights article, re-measure the masthead with seven items under blocked webfonts, and rule.** The two candidate fixes are raising the desktop breakpoint past 1091 px — which changes the normal-settings layout in that band — or giving Geist a metric-matched `size-adjust` fallback; both are outside the scope the header step was given, and both close (a) and (b) together. **Why this is a §12 row and not only a `docs/06` line:** a cutover checklist fires once, at cutover, and this arms itself later, on an editorial decision taken by someone who will not be reading the deployment spec. `docs/02` §Reflow carries the measurements | 2026-09-01 | It is latent today and invisible from inside the repo: six nav items never wrap, so every check passes, and the defect appears the day a second article ships. That is R13's shape — a temporary state that becomes permanent because nothing prompts anyone — with the added twist that the prompt would have to fire on a content event. Raised by `adversarial-reviewer`, round 2 | | R21 | 🛑 **`/legal/privacy/` PUBLISHES CLAIMS ABOUT SYSTEMS OUTSIDE THIS REPOSITORY, AND NOTHING IN AWS, GOOGLE WORKSPACE, THE BUILD OR THIS REPO REPORTS WHEN ANY OF THEM CHANGES.** ⚠️ **RE-SCOPED AGAIN 2026-09-02 BY POUYA'S MECHANICS RULING — AND READ THE NEXT SENTENCE BEFORE TREATING THAT AS RELIEF. THE LIVE CLAIMS ARE NAMED, NOT COUNTED**: the headline of this row said FOUR over a list of five, then TWO over a list of three, in successive versions of the row whose whole subject is a fact going stale unnoticed (`adversarial-reviewer`, rounds 1 and 2). **What `/legal/privacy/` publishes now, in full:** (1) that the record in the table can be read by Pouya and the small number of people who administer the account it sits in with him; (2) that the receiving system can only add a record and cannot read the table back — **(ii)**'s first half; (3) that `info@smlcompany.ca` is read by Pouya and administrative staff — **(iv)**; and (4) that the table sits in an Amazon Web Services account that also runs systems unrelated to this practice, in §Where it is stored. **Deleted from the page and published nowhere now: (iii) root custody, (v) the single-sign-on and resource-policy findings, (ii)'s second half — the deploy credential's lack of access — and (i)'s recital of the method.** ⚠️ **THE FALSIFIERS BELOW ARE UNCHANGED AND SO IS THE TRIGGER.** Every deleted sentence is still asserted by **§7**, still cited by `docs/06`, and still what `docs/reference/intake-table-access-verification.md` exists to prove. **A claim moved off a public page into a register is still a claim — and it is one fewer reader likely to notice it going stale.** What genuinely improved: several falsifiers can now only make a *record* false rather than a public page. Original scoping follows, kept in full because §7 still asserts every one of these. ⚠️ **RE-SCOPED 2026-09-02 BY THE Q63 RULING, AND THE HEADLINE CLAIM IT WAS WRITTEN FOR IS GONE:** the page no longer publishes a **count of people** — Pouya ruled that a simulation counts identities and not humans, so *"two people can"* came off and *"the account's administrators — me, and the small number of people who administer it with me"* went on. **That is a weaker claim and therefore a more durable one: adding an administrator no longer falsifies the page.** The five claims as scoped before the mechanics cut: **(i)** that every user and every role was enumerated and only administrative identities can read the table; **(ii)** that the writing function cannot read it back and the deploy credential has no access at all; **(iii)** that the account's root credential has no programmatic key and Pouya holds it — ⚠️ **held, not held EXCLUSIVELY: nothing measured or attested rules out a second holder, and §9 Q64 is the one line that would settle it**; **(iv)** that `info@smlcompany.ca` is read by Pouya and administrative staff; **(v)** that the table carries no resource-based policy of its own and the account has no single sign-on or federated login. **(iv) is an ATTESTATION and nothing in this repo can check it** — the other three are re-runnable. **Attach a DynamoDB policy to any role, grant the deploy or Lambda role a read, **put a resource-based policy on the table itself** (invisible to `describe-table` and to every principal simulation — it grants from the other side, and it is claim (v) below), **join the account to an AWS Organization** (which would make the Identity Center zero local rather than conclusive), create a root access key, move root custody away from Pouya, delegate the mailbox more widely, or resolve Q23's Gitea dependency by changing `lars`'s access — and the privacy policy becomes false with every check still green.** Original text follows. **THE PAGE SAID "TWO", MEASURED (§7).** **Add a third administrator, remove `lars` from `admins`, attach a DynamoDB policy to any of the 33 roles, **hand the root credential to a third person**, or resolve Q23's Gitea dependency by changing his access — and the privacy policy becomes false with every check still green.** `check:claims`'s `sole-administrator-q62` pattern does **not** cover this: it bars the OLD false shape from returning and is blind to the world moving under the NEW sentence. That asymmetry is the whole reason this row exists. **THE TRIGGER, and it is the same event trigger R10 and R18 use: re-run the verification in `docs/reference/intake-table-access-verification.md` before any cutover, and before any republish that turns on it. Then re-stamp §7 the same day.** The counts are the assertion — seven decisions per role call, six per CDK-path call, two per-resource decisions per assume call — because a call that silently received one bogus action name answers `implicitDeny` and reads exactly like a clean row, which is how the first run of that sweep produced 22 uniformly clean rows and no measurement at all. **Removal is the live direction:** Q23 records the Gitea instance as jointly administered and blocked on its second administrator, so `lars`'s access is plausibly load-bearing elsewhere — and if it goes, `check-claims.mjs`'s `rule:` line carries the instruction (rewrite the page, then narrow the pattern deliberately). Raised by `adversarial-reviewer`, D20 cutover pass round 1, 2026-09-02: R9's and R19's shape at R19's stakes — a statement to the public on a privacy policy, backed by a fact with no owner. | 2026-09-02 | The 2026-09-01 verification is a photograph of a shared AWS account that two people administer and that runs four other projects. A stamp with no trigger behind it goes stale silently, and §4 already records OCNI as the precedent for exactly that | +| R22 | ⚠️ **THE TWO BUNDLED `@aws-sdk` CLIENTS ARE PRODUCTION DEPENDENCIES THAT NO PIN IN THIS REPOSITORY CONSTRAINS, AND NOTHING BUT A DELIBERATE RE-READ REPORTS WHEN THEY MOVE.** `docs/09` §5.5 resolves `@aws-sdk/client-dynamodb` and `@aws-sdk/client-sesv2` from the registry **at package time**, so every handler deploy takes whatever is current. They are **not** in `package.json` and never will be — so **R11's phase-boundary check does not reach them**, because R11's own instruction is scoped to *"every pin in `package.json`"*. **This row is that extension: at every R11 phase boundary, and after every `docs/09` Part 5 run, read the two versions out of the deployed zip and re-stamp §7.** ⚠️ **§7's bundled-SDK row asserted that a reminder covered them while no such reminder existed** — the Q22 shape, a documented control that is not there. Raised by `adversarial-reviewer`, round 2, 2026-09-04. §7 holds the current values; this row holds the obligation to re-read them | 2026-09-04 | Measured: they moved **`3.1125.0` → `3.1126.0`** on 2026-09-04, hours after §7 first recorded them, because a redeploy re-resolved them. **No file in this repository changed**, so no diff, no check and no build could have said so. A dependency that can move without a diff has no other tripwire | | ~~R7~~ | **RATIFIED / SUPERSEDED 2026-08-26.** (a) Cache-policy table matching the pipeline — **accepted**; documenting what the pipeline does beats documenting an intention. (b) `s3:AbortMultipartUpload` omitted — **accepted, reasoning corrected**: the lifecycle rule does not exist and is therefore not the cover; the actual cover is that `aws s3 sync` only goes multipart above 8 MB and the largest asset is a 357 KB portrait. Recorded in `docs/06-deployment.md` with a revisit trigger. (c) The `aws s3 ls` pre-flight — **superseded** by the variable guard now running as the workflow's first step | 2026-08-26 | — | | ~~R8~~ | **PROMOTED TO A RULE 2026-08-26.** A reminder was too weak for a pattern that survived three entries. *A sweep is a command, not a claim* now sits in `CLAUDE.md` under Conventions, in `/build` Phase 6, and in `/wrap` step 3: any claim that a change was applied across files must cite the command and be written only after reading its output | 2026-08-26 | — | @@ -956,6 +957,157 @@ never being raised again. # Change Log +## 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 +clone to OMIT any SecurityHeadersConfig member the source does not define — never +emit an empty object — and add a dry-run assertion that the generated policy JSON +contains no empty sub-objects, so the dry run catches this class before an +--apply does. Record the incident in docs/09 Part 3 with the exact error. Also: +the handler was redeployed 2026-09-04 via 5.5 — re-read the two @aws-sdk pins in +the bundle and update §7 if they moved from 3.1125.0. No other changes."* + +--- + +### 1. What failed, and the state it left + +`create-response-headers-policy` was rejected **client-side**, before the call +left the machine: + +``` +An error occurred (ParamValidation): Parameter validation failed: +Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "Override" +Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "ContentSecurityPolicy" +``` + +**The cause generalises past this script: a config AWS hands back is not +necessarily a config AWS will accept.** `get-response-headers-policy` on +`Managed-SecurityHeadersPolicy` returns `"ContentSecurityPolicy": {}` for a +member it does not define. Reproduced offline with the endpoint pointed at a +dead local socket, so no call could reach AWS. + +**Nothing had reached the distribution, and that was verified rather than +assumed** `[2026-09-04]`: `/api/*` still on `b689b0a8-…`, no `*.pdf` behaviour, +**zero** custom response-headers policies, **zero** custom origin request +policies. + +### 2. The rule was derived from the API model, and the first two attempts at it were wrong + +Walking every structure reachable from `ResponseHeadersPolicyConfig` in the CLI's +own service model: **15 of 16 declare at least one required field**, so `{}` is +not a legal value there and can only be the placeholder. The **one** exception is +`SecurityHeadersConfig` itself — which section 4 already skips on when empty, +because a PDF policy cloning no security headers is what that section exists to +prevent. + +**Attempt 1 stripped only `SecurityHeadersConfig`**, on the stated ground that the +reasoning had been checked for one shape only. The four sibling members were +still cloned by a truthiness test, and `{}` is truthy — so the same placeholder +one level up became a **hard abort** instead of an omission. **Attempt 2 stripped +two levels** and aborted on the third (`CorsConfig.AccessControlExposeHeaders`). +Both found by `adversarial-reviewer` by fault-injecting the AWS responses, not by +reading. **The strip is now recursive**, which the measurement above licenses at +every depth. + +⚠️ **AND THE ASSERTION IS A SKIP, NOT A THROW — it was a throw for two rounds.** +An empty object is section 4's problem alone, and this file's contract is that +section 4 never blocks sections 1–3 from re-applying `router.js`, without which +22 of 23 pages return S3's `AccessDenied`. Measured with the strip broken: the run +**exits 0**, section 4 skips by name, and **section 5 still produces its two +changes**. + +### 3. The two functions moved to their own module so the claim about them is checkable + +`infra/cloudfront/policy-shapes.mjs` + `policy-shapes.test.mjs` — **23 cases, 7 of +7 mutations killed**. `configure.mjs` reads argv and calls AWS at import time, so +the functions could not otherwise be reached; this is `fields.mjs`'s precedent +beside `handler.mjs`, and the same reason. **R14**: the runbook previously said +*"proven both ways"* about a session nobody could re-enter, and one half of that +proof required editing the script on a machine holding live credentials. It now +cites `node infra/cloudfront/policy-shapes.test.mjs`. Its first case is the +incident verbatim. + +### 4. Records + +- **`docs/09` Part 3** — the incident, the exact error, the post-failure state, + and the orphan check. ⚠️ **The "one reviewable transaction" property is about + the DISTRIBUTION, not the account**, and this record asserted the wider version + for one round: sections 4 and 5 each `create-*` **before** `update-distribution`, + and the script's own comment documents a path where section 4's policy survives + section 5's abort. Both policy lists are now run and both results reported. +- **`docs/09` Part 3's Expect block** said the output was *"exactly"* 14 lines. It + is **60**, and the correction itself then quantified the gap at one line and + enumerated only 56 of the 60. All 60 now account for, with the `·`/`+` split + attributed to the state it was measured in. +- **`docs/09` §5.5, `docs/05` §34 and `docs/06`** each carried their own copy of + the deployed commit and file count. §5.5's opening paragraph argues a literal + there *"would be stale the week after it was written"*; **it went stale in two + days**. `docs/05` named the commit in the same sentence as *"§7 holds the full + state and this spec does not restate it"*. All three now cite §7. + +### 5. §7, re-read against production + +- **Intake Lambda** — code **3,314,989** bytes (was 3,307,021), `CodeSha256` + `oMVpQYFxvjGCA6Wbz6UQGnQlS2QgVndT4BdoiCqsP1o=`, modified **2026-09-04T15:53:36Z**, + and all **three** source files (`spam-score.mjs` now among them) byte-identical + to commit **`3c3ba5d`**. ⚠️ **The row said "4,277 files"** — that is `unzip -l`'s + own footer wording, and **702 of them are directories**. The file count is + **3,575**. An operator re-deriving it with `find -type f` would have hit a + 702-entry mismatch and no way to tell that from a truncated bundle. +- **The two bundled SDK pins moved `3.1125.0` → `3.1126.0`**, hours after the row + that first recorded them predicted exactly that. **The prediction is now a + measurement**: two production dependencies changed version and no file in this + repository changed. +- **§12 R22** — because that row named itself as the reminder covering them while + **no such reminder existed** (the Q22 shape). R11 is scoped to `package.json` + and these two are deliberately not in it. R22 is that extension. + +### 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: 8** — five of +the eight were defects in round 1's own repairs, which is the measurement D19's +cap rests on. All 15 fixed, none declined. **No round 3.** + +### 7. Not done, and flagged to Pouya rather than built + +**`configure.mjs` validates `--api-domain` but not `--function-arn`.** Found by +tripping it: an ad-hoc `list-functions` query returned the ARN **tab-doubled** +(the function exists in two stages), and the dry run duly staged replacing the +working router association with the doubled value — which on an `--apply` takes +22 of 23 pages down. **The runbook is not exposed** — Part 2 derives it with +`describe-function --stage LIVE`, which returns one — so this was a broken +instrument, not a repo defect. Six lines mirroring the existing guard would close +it; not built, because the ruling said *"no other changes"*. + +### 8. Sweeps, with the commands + +``` +$ grep -rn "3\.1125\.0" --include='*.md' --include='*.mjs' . | grep -v node_modules | grep -v '^./dist/' +AGENTS.md:732 (the new row, quoting the superseded value deliberately) +AGENTS.md:1188 (Change Log entry (at) — never edited) + +$ grep -rn "02739ad" --include='*.md' --include='*.mjs' . | grep -v node_modules | grep -v '^./dist/' +docs/05-backend-spec.md:36 (the correction notice, quoting the old value) +docs/06-deployment.md:797 (the correction notice, quoting the old value) +``` + +Both live restatements are gone; what remains quotes the superseded value inside +a notice saying it was superseded. **All AGENTS.md edits are in Current Truth** +— §7 rows 728 and 731, §12 row 952 — and **none is in the Change Log**, asserted +against the `# Change Log` line number rather than by eye. + +**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 (23/23) · `spam-score.test` 0 +(39/39) · minifier grep 1 (clean) · live dry run 0 (60 lines, 4 changes, 0 skips, +nothing written). **`lighthouse` not run** — this change set touches no file under +`src/` or `dist/`. + +**Nothing was applied to the distribution and nothing was deployed.** + +--- + ## 2026-09-04 (at) — D20 finding 10 is PRICED rather than narrowed and finding 13 is ATTESTED, closing the pass at 17/2/1; the first real spam gets a second honeypot and a scorer that labels; the timing floor Pouya ruled on turns out never to have existed; and §7 told an operator the intake backend was undeployed for two days after it went live **Pouya's rulings of 2026-09-03 (the two remaining D20 findings) and 2026-09-04 diff --git a/docs/05-backend-spec.md b/docs/05-backend-spec.md index b78aea8..24cd74e 100644 --- a/docs/05-backend-spec.md +++ b/docs/05-backend-spec.md @@ -31,9 +31,12 @@ behaviour the form posts to does not exist"* — true when written under D11, fa from the moment `docs/09` Parts 3, 5 and 6 ran at cutover, and two days stale in the document an implementer reads before touching the handler. **Measured 2026-09-04:** the function carries `handler.handler` with six environment -variables and its two source entries are byte-identical to commit `02739ad`; the API has +variables and its deployed source entries match a commit **§7 names**; the API has exactly one route, `POST /api/intake`; §7 holds the full state and this spec does -not restate it. `/contact/` still publishes the email address beside the form, +not restate it. ⚠️ **THIS SENTENCE NAMED THE COMMIT — `02739ad` — IN THE SAME +BREATH AS DISCLAIMING RESTATEMENT, AND THE 2026-09-04 REDEPLOY MADE IT FALSE** +(three source entries now, matching a later commit). The count is gone with it: +both were facts §7 owns. `/contact/` still publishes the email address beside the form, which is now a courtesy rather than a fallback. ### The form is a plain HTML POST, and it answers 303 diff --git a/docs/06-deployment.md b/docs/06-deployment.md index 4ba1170..98a5aa9 100644 --- a/docs/06-deployment.md +++ b/docs/06-deployment.md @@ -792,8 +792,11 @@ the decision is re-readable rather than re-litigated. those two findings was a bare POST with no `Origin` header, which the handler rejects by design. `docs/09` §7.1 run correctly returns **303**, and on 2026-09-04 the function's own configuration and its deployed artefact - were read: `handler.handler`, six environment variables, both source files - byte-identical to commit `02739ad`. The paragraph above is preserved as what the pass + were read: `handler.handler`, six environment variables, and source files + byte-identical to the commit **§7 records**. ⚠️ **THIS SAID "both source + files … `02739ad`" AND THE REDEPLOY LATER THAT DAY MADE BOTH HALVES + FALSE** — three files now, and a later commit. A count and a commit are + §7's to hold; this line cites it. The paragraph above is preserved as what the pass found; **only findings 10, 11 and 13 outlived it, and 10 and 13 are now ruled** — see item 3 of the callout near the top of this file, which is the current tally and this is not. diff --git a/docs/09-cutover-runbook.md b/docs/09-cutover-runbook.md index c1f01c4..4af7961 100644 --- a/docs/09-cutover-runbook.md +++ b/docs/09-cutover-runbook.md @@ -321,8 +321,24 @@ node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN" --function-arn "$ROUTER_ARN" ``` -**Expect** — this is the dry run, and the output on a distribution in the state -Part 0.3 records is exactly: +⚠️ **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 +the **live** distribution the dry run is **60 lines**, and all 60 account for: +2 resolved-policy lines, **10** `·` lines, **4** `+` lines, a **40-line JSON +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.` +footer `[measured 2026-09-04, exit 0, nothing written]`. + +⚠️ **THAT 10/4 SPLIT IS THE LIVE STATE, NOT THE STATE THE BLOCK BELOW +DESCRIBES.** Each of the four things Parts 1–3 have already applied prints `·` +when it is found and `+` when it is staged, so in the Part 0.3 state those four +move across: **6** `·` and **8** `+`, same 60 lines. **The total is not the +check — the `+` lines are.** An operator told the output is "exactly" 14 lines +either stops on a wall of unexplained text or stops comparing, and the paragraph +after the block tells them a different set means *"stop and re-read it"*. + +**Expect** — this is the dry run, and on a distribution in the state Part 0.3 +records the change lines are: ``` resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad @@ -359,6 +375,96 @@ and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98 `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. +🛑 **INCIDENT, 2026-09-04 — THE FIRST `--apply` FAILED ON ITS FIRST WRITE, AND +NOTHING REACHED THE DISTRIBUTION.** 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: +Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "Override" +Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "ContentSecurityPolicy" +``` + +**The cause, and it generalises past this script: a config AWS hands back is not +necessarily a config AWS will accept.** `get-response-headers-policy` on +`Managed-SecurityHeadersPolicy` returns `"ContentSecurityPolicy": {}` — an empty +object standing for a member the policy does not define — and +`ResponseHeadersPolicySecurityHeadersConfig` has **no required members** while +**every one of its six members requires at least `Override`**. So an empty member +is always "undefined here" and is **never** a legal input. + +⚠️ **AND THE SAME IS TRUE ONE LEVEL UP, WHICH THE FIRST FIX MISSED.** Every +sibling member of `ResponseHeadersPolicyConfig` also declares required fields — +`CorsConfig` five of them, `RemoveHeadersConfig` and `CustomHeadersConfig` a +`Quantity`, `ServerTimingHeadersConfig` an `Enabled` — while the container +itself requires only `Name`. So `{}` is a placeholder at **both** levels, and a +fix covering only the inner one turns the outer placeholder into a hard abort +instead of an omission. All of that is read out of the CLI's own service model, +not inferred from the symptom. + +**State after the failure** ``[verified 2026-09-04 — `get-distribution-config`, `list-response-headers-policies --type custom`, `list-origin-request-policies --type custom`]``**:** `/api/*` still on +`b689b0a8-53d0-40ab-baf2-68738e2966ac`, **no** `*.pdf` behaviour, **zero** custom +response-headers policies, **zero** custom origin request policies. + +⚠️ **THE "ONE REVIEWABLE TRANSACTION" PROPERTY IS ABOUT THE DISTRIBUTION, NOT +THE ACCOUNT — AND THIS RECORD ASSERTED THE WIDER VERSION FOR ONE ROUND.** +`update-distribution` is the script's last call, so a throw above it does leave +the **distribution** untouched. But sections 4 and 5 each make their own write +first — `create-response-headers-policy` and `create-origin-request-policy` — +and the script's own comment on section 5's drift throw documents a reachable +path where section 4 has **already created `adr-sml-pdf-noindex`** when section +5 aborts. **So after any failed `--apply`, check for an orphaned policy as well +as for a changed distribution**, with both of these: + +```bash +aws cloudfront list-response-headers-policies --type custom --output json \ + --query 'ResponseHeadersPolicyList.Quantity' +aws cloudfront list-origin-request-policies --type custom --output json \ + --query 'OriginRequestPolicyList.Quantity' +``` + +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 +the 2026-09-04 failure, which is why nothing needed doing that time +`[verified 2026-09-04]`. + +**Two changes came out of it.** The clone now **omits** any empty member at +either of those two levels — a `ResponseHeadersPolicyConfig` member, or a +`SecurityHeadersConfig` member — and the dry run **asserts** that the generated +config carries no empty object at any *other* level, naming the dotted path if +it does. The asymmetry is deliberate: those two levels are where AWS is known to +synthesise a placeholder, and anywhere else is unaccounted for and stops the +run rather than being discarded quietly. The assertion runs before every branch, so **the dry run now catches this +class** rather than an `--apply` discovering it — and if it ever does fire it +**skips section 4** rather than throwing, so `router.js` can still be +re-applied. + +**The proof is a command rather than a session**, which is the point of +`infra/cloudfront/policy-shapes.mjs` existing as its own module — `configure.mjs` +reads argv and calls AWS at import time, so the two functions could not +otherwise be reached: + +```bash +node infra/cloudfront/policy-shapes.test.mjs +``` + +**Expect** `policy-shapes: 23 of 23 cases pass`, exit 0. Its first case is this +incident verbatim — the live `SecurityHeadersConfig`, empty `ContentSecurityPolicy` +and all `[verified 2026-09-04; 7 of 7 mutations killed]`. + +**One of the `·` lines carries a number worth reading**, and it is not a +warning: + +``` +· cloning 5 defined security header(s); omitting 1 the source does not define (ContentSecurityPolicy) +``` + +**Five is the number to read.** It is the count of security headers the PDF +policy will carry, and the verification block at the end of this Part counts the +same five arriving on the live PDF. A drop in this number is a partial clone +announcing itself one step earlier than that `curl` would. + ⚠️ **RUN IT WITHOUT `--function-arn` ONLY IF THE ROUTER IS ALREADY ATTACHED.** Omitting the flag prints `· no --function-arn given, leaving FunctionAssociations alone` and skips change 1 — which is right on a re-run and wrong on a first one, @@ -734,12 +840,14 @@ file: `CLAUDE.md`'s rule is that a version is checked against the registry and never recalled, and a literal here would be stale the week after it was written. ⚠️ **THIS PATH WAS TAKEN — 2026-09-02, and the live function is the bundled -zip** `[measured 2026-09-04 — the deployed artefact was downloaded via -`get-function` `Code.Location` and read]`. Its two source entries were -byte-identical to commit `02739ad`, and the two packages inside it are -**`@aws-sdk/client-dynamodb@3.1125.0`** and **`@aws-sdk/client-sesv2@3.1125.0`**. -`AGENTS.md` §7 now records them, which this step required in terms and which did -not happen at the time. +zip.** ⚠️ **THE TWO RESOLVED VERSIONS AND THE COMMIT THE DEPLOYED SOURCES MATCH +ARE IN `AGENTS.md` §7 AND ARE DELIBERATELY NOT REPEATED HERE.** They were +repeated here until 2026-09-04, and the paragraph directly above is the argument +against it — **the literals went stale in two days rather than a week**: the +2026-09-04 redeploy moved both packages one patch and added a third source file, +and this copy still named the old versions and a superseded commit while reading +as a measurement. Read §7's two Lambda rows; this step's duty is to **update** +them, not to mirror them. ✅ **THE `cp` AND `zip` LINES BELOW DERIVE THE FILE LIST THE SAME WAY 5.1 DOES.** They were a second hand-typed copy until 2026-09-04, not derived from 5.1's and diff --git a/infra/cloudfront/configure.mjs b/infra/cloudfront/configure.mjs index 3a3865e..214484d 100644 --- a/infra/cloudfront/configure.mjs +++ b/infra/cloudfront/configure.mjs @@ -41,6 +41,14 @@ */ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; +/* Section 4's clone, and why an empty member is always AWS's placeholder + rather than a value: `policy-shapes.mjs`. Tested there, because this file + reads argv and calls AWS at import time. */ +import { + withoutEmptyMembers, + isEmptyObject, + emptyObjectPaths, +} from './policy-shapes.mjs'; const args = process.argv.slice(2); const flag = (name) => { @@ -383,18 +391,49 @@ if (!defaultRhpId) { instead, naming the diff. */ const source = getResponseHeadersPolicy(defaultRhpId); const srcCfg = source?.ResponseHeadersPolicy?.ResponseHeadersPolicyConfig; + const clonedShc = withoutEmptyMembers(srcCfg?.SecurityHeadersConfig); + const undefinedMembers = Object.keys( + srcCfg?.SecurityHeadersConfig ?? {}, + ).filter((k) => !(k in clonedShc)); /* ⚠️ SKIP, NOT THROW — same rule as the missing-id case above, and it was inconsistent for one round. A policy carrying only `CorsConfig` is legal; an ABSENT source is section 4's problem alone and must not stop sections - 1-3 from re-applying `router.js`. The DRIFT throw below is different: that - is a divergence, not an absence, and `docs/09` Part 3 argues for it. */ + 1-3 from re-applying `router.js`. TWO THROWS BELOW ARE DELIBERATE + 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 + EMPTY-OBJECT throw means this script generated an invalid config — a bug in + the script, not a state of the world. Neither is something a later section + should be allowed to paper over. */ if (!srcCfg?.SecurityHeadersConfig) { skipped.push( `${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`, ); + } else if (Object.keys(clonedShc).length === 0) { + /* Distinct from the branch above, and the distinction is the operator's: + ABSENT means the source policy is a different kind of thing, while + ALL-EMPTY means it is the right kind and defines nothing. Both leave the + PDF without security headers; only the second would read as a bug. */ + skipped.push( + `${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has a SecurityHeadersConfig that defines nothing` + + (undefinedMembers.length + ? ` (all ${undefinedMembers.length} of its members are empty: ${undefinedMembers.join(', ')})` + : ' — it is an empty object') + + `, so there are no security headers to clone`, + ); } else { - const wanted = { - SecurityHeadersConfig: srcCfg.SecurityHeadersConfig, + /* ⚠️ UNCONDITIONAL. This was gated on `undefinedMembers.length`, so on a + source that defines all six the line DISAPPEARED rather than reading six + — and `docs/09` Part 3 tells the operator to read this number as the + count of headers the PDF will carry. An absent line is not a smaller + number; it is nothing to compare (`adversarial-reviewer`). */ + console.log( + `· cloning ${Object.keys(clonedShc).length} defined security header(s)` + + (undefinedMembers.length + ? `; omitting ${undefinedMembers.length} the source does not define (${undefinedMembers.join(', ')})` + : ''), + ); + const wanted = withoutEmptyMembers({ + SecurityHeadersConfig: clonedShc, ...(srcCfg.CorsConfig ? { CorsConfig: srcCfg.CorsConfig } : {}), ...(srcCfg.RemoveHeadersConfig ? { RemoveHeadersConfig: srcCfg.RemoveHeadersConfig } @@ -406,176 +445,210 @@ if (!defaultRhpId) { Quantity: (srcCfg.CustomHeadersConfig?.Items ?? []).length + 1, Items: [...(srcCfg.CustomHeadersConfig?.Items ?? []), XRT], }, - }; + }); - if (existingPdfPolicy) { - const have = existingPdfPolicy.ResponseHeadersPolicyConfig; - const norm = (o) => JSON.stringify(o ?? null); - const drift = [ - 'SecurityHeadersConfig', - 'CorsConfig', - 'RemoveHeadersConfig', - 'ServerTimingHeadersConfig', - ] - .filter((k) => norm(have[k]) !== norm(wanted[k])) - .concat( - norm(have.CustomHeadersConfig?.Items) !== - norm(wanted.CustomHeadersConfig.Items) - ? ['CustomHeadersConfig'] - : [], - ); - if (drift.length) { - /* Print BOTH SIDES of every drifted key. Naming the field alone does not - tell the operator which header moved, nor which direction to reconcile - in — the same message fires whether the source gained a header or the - PDF policy lost its X-Robots-Tag, and those need opposite repairs. */ - const detail = drift - .map( - (k) => - ` ${k}\n pdf policy : ${norm( - k === 'CustomHeadersConfig' - ? have.CustomHeadersConfig?.Items - : have[k], - )}\n default : ${norm( - k === 'CustomHeadersConfig' - ? wanted.CustomHeadersConfig.Items - : wanted[k], - )}`, - ) - .join('\n'); - throw new Error( - `${PDF_POLICY_NAME} has DRIFTED from the default behaviour's policy ` + - `${defaultRhpId} on ${drift.length} field(s). The PDF is being served ` + - `different headers from the pages — read which way before repairing:\n` + - `${detail}\n` + - `Reconcile with update-response-headers-policy (it needs the policy's ` + - `own ETag), then re-run. This script will not silently paper over it.`, - ); - } - console.log( - `· response-headers policy ${PDF_POLICY_NAME} exists and matches the default behaviour`, - ); - } else if (!APPLY) { - console.log(`· would CREATE response-headers policy ${PDF_POLICY_NAME}`); - changes.push( - `create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`, + /* ⚠️ ASSERT BEFORE ANY BRANCH, SO THE DRY RUN CARRIES IT TOO — Pouya's + ruling of 2026-09-04. The clone that failed was syntactically fine and + printed a clean dry run; a check that runs only on the writing path + reports the class after it has already cost the run. + + ⚠️ A SKIP, NOT A THROW — it was a throw for one round. An empty object + here is section 4's problem alone, and this file's contract is that + section 4 never blocks sections 1-3 from re-applying `router.js`, without + which 22 of 23 pages return S3's AccessDenied. A skip is already loud: it + prints under its own heading and the NOTHING TO CHANGE guard names it. */ + const empties = emptyObjectPaths(wanted); + if (empties.length) { + skipped.push( + `${PDF_PATTERN} / ${PDF_POLICY_NAME} — the generated policy config still ` + + `contains ${empties.length} empty object(s) (${empties.join(', ')}), and ` + + `AWS rejects those 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.`, ); } else { - const created = aws([ - 'cloudfront', - 'create-response-headers-policy', - '--response-headers-policy-config', - JSON.stringify({ - Name: PDF_POLICY_NAME, - Comment: - 'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.', - ...wanted, - }), - '--output', - 'json', - ]); - pdfPolicyId = created?.ResponseHeadersPolicy?.Id; - if (!pdfPolicyId) { - throw new Error('create-response-headers-policy returned no Id'); - } - console.log( - `created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`, - ); - changes.push( - `created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`, - ); - } - - const pdfBehaviours = cfg.CacheBehaviors?.Items ?? []; - const foundPdf = pdfBehaviours.find((b) => b.PathPattern === PDF_PATTERN); - if (foundPdf) { - /* ⚠️ PRESENCE IS NOT CORRECTNESS. This checked only that a `*.pdf` - behaviour existed, so one added by hand — while chasing the - `aws s3 sync --metadata` route this file's header records as the original - instruction — would report `already present`, push nothing, and print - NOTHING TO CHANGE while the PDF served no `X-Robots-Tag` at all. Section 1 - compares the FunctionARN before declaring a match; so does this now. */ - const wrong = []; - if (foundPdf.ResponseHeadersPolicyId !== pdfPolicyId) { - wrong.push( - `ResponseHeadersPolicyId is ${foundPdf.ResponseHeadersPolicyId ?? '(none)'}, expected ${pdfPolicyId ?? '(the policy this script manages)'}`, - ); - } - if (foundPdf.TargetOriginId !== cfg.DefaultCacheBehavior.TargetOriginId) { - wrong.push( - `TargetOriginId is ${foundPdf.TargetOriginId}, expected ${cfg.DefaultCacheBehavior.TargetOriginId}`, - ); - } - const hasViewerRequest = ( - foundPdf.FunctionAssociations?.Items ?? [] - ).some((i) => i.EventType === 'viewer-request'); - if (!hasViewerRequest) { - wrong.push( - 'no viewer-request FunctionAssociation — router.js normalises `//` and `\\` on file paths, so `//pouya-lajevardi-bio.pdf` would 404 instead of 301', - ); - } - if (wrong.length) { - throw new Error( - `a ${PDF_PATTERN} cache behaviour already exists but is NOT the one this ` + - `script manages:\n - ${wrong.join('\n - ')}\n` + - `Reconcile or remove it before re-running; this script will not adopt ` + - `a behaviour it cannot account for.`, - ); - } - console.log( - `· cache behaviour ${PDF_PATTERN} already present and correctly configured`, - ); - } else { - const d = cfg.DefaultCacheBehavior; - const behaviour = { - PathPattern: PDF_PATTERN, - TargetOriginId: d.TargetOriginId, - ViewerProtocolPolicy: d.ViewerProtocolPolicy, - AllowedMethods: d.AllowedMethods, - CachePolicyId: d.CachePolicyId, - /* Placeholder only in a dry run — the real id exists by the time --apply - reaches this line, because the branch above created it. */ - ResponseHeadersPolicyId: pdfPolicyId ?? '', - Compress: d.Compress, - SmoothStreaming: false, - FieldLevelEncryptionId: '', - /* ⚠️ THE ROUTER FUNCTION IS ATTACHED, AND IT IS NOT A NO-OP ON FILE PATHS. - `router.js` normalises `\` to `/` and collapses a leading `//` run - BEFORE it tests for an extension, and 301s when normalisation changed - anything — so `//pouya-lajevardi-bio.pdf` redirects to the canonical - path today. Omitting the association here would silently drop that and - hand S3 the doubled key instead. The `/api/*` reason for omitting it — - a 301 turning a POST into a GET and losing the body — does not apply to - a GET-only PDF. */ - FunctionAssociations: d.FunctionAssociations ?? { Quantity: 0 }, - LambdaFunctionAssociations: { Quantity: 0 }, - TrustedKeyGroups: { Enabled: false, Quantity: 0 }, - }; - /* ⚠️ STAGE THE REPORT EVEN WHEN THE ID IS NOT KNOWN YET. The dry run's whole - job is to show what would touch a distribution serving 23 pages; reporting - only the harmless policy creation and staying silent about the behaviour - would mean the first sight of it is `update-distribution` writing it. The - `cfg` mutation stays gated on a real id; the REPORT does not. */ - changes.push( - `CacheBehaviors += ${PDF_PATTERN} -> ${d.TargetOriginId}, default cache policy, ${PDF_POLICY_NAME}` + - (pdfPolicyId - ? ` (${pdfPolicyId})` - : ' (policy id created in the same --apply pass)'), - ); - if (!APPLY && !pdfPolicyId) { + if (existingPdfPolicy) { + const have = existingPdfPolicy.ResponseHeadersPolicyConfig; + /* ⚠️ `{}` AND ABSENT MUST NORMALISE ALIKE, ON BOTH SIDES. `wanted` has + had AWS's placeholders stripped; the live policy may still echo them. + Stripping alone is not enough — that leaves `norm({})` as `"{}"` + against `norm(undefined)` as `"null"`, which reports drift that no + `update-response-headers-policy` can clear, on the intake form's own + path. An empty member is never a real divergence. */ + const norm = (o) => { + const v = withoutEmptyMembers(o); + return JSON.stringify(isEmptyObject(v) ? null : (v ?? null)); + }; + const drift = [ + 'SecurityHeadersConfig', + 'CorsConfig', + 'RemoveHeadersConfig', + 'ServerTimingHeadersConfig', + ] + .filter((k) => norm(have[k]) !== norm(wanted[k])) + .concat( + norm(have.CustomHeadersConfig?.Items) !== + norm(wanted.CustomHeadersConfig.Items) + ? ['CustomHeadersConfig'] + : [], + ); + if (drift.length) { + /* Print BOTH SIDES of every drifted key. Naming the field alone does not + tell the operator which header moved, nor which direction to reconcile + in — the same message fires whether the source gained a header or the + PDF policy lost its X-Robots-Tag, and those need opposite repairs. */ + const detail = drift + .map( + (k) => + ` ${k}\n pdf policy : ${norm( + k === 'CustomHeadersConfig' + ? have.CustomHeadersConfig?.Items + : have[k], + )}\n default : ${norm( + k === 'CustomHeadersConfig' + ? wanted.CustomHeadersConfig.Items + : wanted[k], + )}`, + ) + .join('\n'); + throw new Error( + `${PDF_POLICY_NAME} has DRIFTED from the default behaviour's policy ` + + `${defaultRhpId} on ${drift.length} field(s). The PDF is being served ` + + `different headers from the pages — read which way before repairing:\n` + + `${detail}\n` + + `Reconcile with update-response-headers-policy (it needs the policy's ` + + `own ETag), then re-run. This script will not silently paper over it.`, + ); + } console.log( - `· would ADD cache behaviour ${PDF_PATTERN}:\n` + - JSON.stringify(behaviour, null, 2) - .split('\n') - .map((l) => ' ' + l) - .join('\n'), + `· response-headers policy ${PDF_POLICY_NAME} exists and matches the default behaviour`, + ); + } else if (!APPLY) { + console.log( + `· would CREATE response-headers policy ${PDF_POLICY_NAME}`, + ); + changes.push( + `create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`, ); } else { - pdfBehaviours.push(behaviour); - cfg.CacheBehaviors = { - Quantity: pdfBehaviours.length, - Items: pdfBehaviours, + const created = aws([ + 'cloudfront', + 'create-response-headers-policy', + '--response-headers-policy-config', + JSON.stringify({ + Name: PDF_POLICY_NAME, + Comment: + 'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.', + ...wanted, + }), + '--output', + 'json', + ]); + pdfPolicyId = created?.ResponseHeadersPolicy?.Id; + if (!pdfPolicyId) { + throw new Error('create-response-headers-policy returned no Id'); + } + console.log( + `created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`, + ); + changes.push( + `created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`, + ); + } + + const pdfBehaviours = cfg.CacheBehaviors?.Items ?? []; + const foundPdf = pdfBehaviours.find((b) => b.PathPattern === PDF_PATTERN); + if (foundPdf) { + /* ⚠️ PRESENCE IS NOT CORRECTNESS. This checked only that a `*.pdf` + behaviour existed, so one added by hand — while chasing the + `aws s3 sync --metadata` route this file's header records as the original + instruction — would report `already present`, push nothing, and print + NOTHING TO CHANGE while the PDF served no `X-Robots-Tag` at all. Section 1 + compares the FunctionARN before declaring a match; so does this now. */ + const wrong = []; + if (foundPdf.ResponseHeadersPolicyId !== pdfPolicyId) { + wrong.push( + `ResponseHeadersPolicyId is ${foundPdf.ResponseHeadersPolicyId ?? '(none)'}, expected ${pdfPolicyId ?? '(the policy this script manages)'}`, + ); + } + if ( + foundPdf.TargetOriginId !== cfg.DefaultCacheBehavior.TargetOriginId + ) { + wrong.push( + `TargetOriginId is ${foundPdf.TargetOriginId}, expected ${cfg.DefaultCacheBehavior.TargetOriginId}`, + ); + } + const hasViewerRequest = ( + foundPdf.FunctionAssociations?.Items ?? [] + ).some((i) => i.EventType === 'viewer-request'); + if (!hasViewerRequest) { + wrong.push( + 'no viewer-request FunctionAssociation — router.js normalises `//` and `\\` on file paths, so `//pouya-lajevardi-bio.pdf` would 404 instead of 301', + ); + } + if (wrong.length) { + throw new Error( + `a ${PDF_PATTERN} cache behaviour already exists but is NOT the one this ` + + `script manages:\n - ${wrong.join('\n - ')}\n` + + `Reconcile or remove it before re-running; this script will not adopt ` + + `a behaviour it cannot account for.`, + ); + } + console.log( + `· cache behaviour ${PDF_PATTERN} already present and correctly configured`, + ); + } else { + const d = cfg.DefaultCacheBehavior; + const behaviour = { + PathPattern: PDF_PATTERN, + TargetOriginId: d.TargetOriginId, + ViewerProtocolPolicy: d.ViewerProtocolPolicy, + AllowedMethods: d.AllowedMethods, + CachePolicyId: d.CachePolicyId, + /* Placeholder only in a dry run — the real id exists by the time --apply + reaches this line, because the branch above created it. */ + ResponseHeadersPolicyId: pdfPolicyId ?? '', + Compress: d.Compress, + SmoothStreaming: false, + FieldLevelEncryptionId: '', + /* ⚠️ THE ROUTER FUNCTION IS ATTACHED, AND IT IS NOT A NO-OP ON FILE PATHS. + `router.js` normalises `\` to `/` and collapses a leading `//` run + BEFORE it tests for an extension, and 301s when normalisation changed + anything — so `//pouya-lajevardi-bio.pdf` redirects to the canonical + path today. Omitting the association here would silently drop that and + hand S3 the doubled key instead. The `/api/*` reason for omitting it — + a 301 turning a POST into a GET and losing the body — does not apply to + a GET-only PDF. */ + FunctionAssociations: d.FunctionAssociations ?? { Quantity: 0 }, + LambdaFunctionAssociations: { Quantity: 0 }, + TrustedKeyGroups: { Enabled: false, Quantity: 0 }, }; + /* ⚠️ STAGE THE REPORT EVEN WHEN THE ID IS NOT KNOWN YET. The dry run's whole + job is to show what would touch a distribution serving 23 pages; reporting + only the harmless policy creation and staying silent about the behaviour + would mean the first sight of it is `update-distribution` writing it. The + `cfg` mutation stays gated on a real id; the REPORT does not. */ + changes.push( + `CacheBehaviors += ${PDF_PATTERN} -> ${d.TargetOriginId}, default cache policy, ${PDF_POLICY_NAME}` + + (pdfPolicyId + ? ` (${pdfPolicyId})` + : ' (policy id created in the same --apply pass)'), + ); + if (!APPLY && !pdfPolicyId) { + console.log( + `· would ADD cache behaviour ${PDF_PATTERN}:\n` + + JSON.stringify(behaviour, null, 2) + .split('\n') + .map((l) => ' ' + l) + .join('\n'), + ); + } else { + pdfBehaviours.push(behaviour); + cfg.CacheBehaviors = { + Quantity: pdfBehaviours.length, + Items: pdfBehaviours, + }; + } } } } diff --git a/infra/cloudfront/policy-shapes.mjs b/infra/cloudfront/policy-shapes.mjs new file mode 100644 index 0000000..615c90e --- /dev/null +++ b/infra/cloudfront/policy-shapes.mjs @@ -0,0 +1,77 @@ +/** + * 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 []; +} diff --git a/infra/cloudfront/policy-shapes.test.mjs b/infra/cloudfront/policy-shapes.test.mjs new file mode 100644 index 0000000..e691773 --- /dev/null +++ b/infra/cloudfront/policy-shapes.test.mjs @@ -0,0 +1,204 @@ +/** + * Tests for `policy-shapes.mjs` — the two functions that answer the 2026-09-04 + * `--apply` failure recorded in `docs/09` Part 3. + * + * The first case is that failure verbatim: the `SecurityHeadersConfig` the live + * `Managed-SecurityHeadersPolicy` returns, empty `ContentSecurityPolicy` and + * all, which is what `create-response-headers-policy` rejected. + * + * node infra/cloudfront/policy-shapes.test.mjs + */ +import { + withoutEmptyMembers, + emptyObjectPaths, + isEmptyObject, +} from './policy-shapes.mjs'; + +let pass = 0; +const failures = []; +const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const t = (name, got, want) => { + if (eq(got, want)) pass += 1; + else + failures.push( + `${name}\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`, + ); +}; + +/* The live source policy, copied from `get-response-headers-policy` on + 67f7725c-6f97-4210-82d7-5512b31e9d03 [verified 2026-09-04]. */ +const LIVE_SECURITY_HEADERS = { + XSSProtection: { Override: false, Protection: true, ModeBlock: true }, + FrameOptions: { Override: false, FrameOption: 'SAMEORIGIN' }, + ReferrerPolicy: { + Override: false, + ReferrerPolicy: 'strict-origin-when-cross-origin', + }, + ContentSecurityPolicy: {}, + ContentTypeOptions: { Override: true }, + StrictTransportSecurity: { + Override: false, + AccessControlMaxAgeSec: 31536000, + }, +}; + +/* ---- the incident itself ------------------------------------------------ */ +const stripped = withoutEmptyMembers(LIVE_SECURITY_HEADERS); +t( + 'the 2026-09-04 breach: ContentSecurityPolicy is dropped', + Object.keys(stripped).sort(), + [ + 'ContentTypeOptions', + 'FrameOptions', + 'ReferrerPolicy', + 'StrictTransportSecurity', + 'XSSProtection', + ], +); +t( + 'and five survive — the count docs/09 Part 3 tells the operator to read', + Object.keys(stripped).length, + 5, +); +t( + 'the surviving members are untouched', + stripped.StrictTransportSecurity, + LIVE_SECURITY_HEADERS.StrictTransportSecurity, +); +t('nothing empty is left behind', emptyObjectPaths(stripped), []); + +/* ---- the placeholder one level up, which a SecurityHeadersConfig-only strip + turned into a hard abort (adversarial-reviewer, round 1) ------------- */ +t( + 'a top-level policy-config member is dropped', + withoutEmptyMembers({ + Name: 'p', + CorsConfig: {}, + SecurityHeadersConfig: stripped, + }), + { Name: 'p', SecurityHeadersConfig: stripped }, +); + +/* ---- and the one BELOW that, which the first repair still aborted on + (adversarial-reviewer, round 2) -------------------------------------- */ +t( + 'a CorsConfig member is dropped, and the emptied CorsConfig with it', + withoutEmptyMembers({ + Name: 'p', + CorsConfig: { AccessControlExposeHeaders: {} }, + }), + { Name: 'p' }, +); +t( + 'but a CorsConfig that still has content survives', + withoutEmptyMembers({ + CorsConfig: { AccessControlExposeHeaders: {}, OriginOverride: false }, + }), + { CorsConfig: { OriginOverride: false } }, +); + +/* ---- things that must NOT be discarded ---------------------------------- */ +t( + 'an empty ARRAY is kept — {Quantity: 0, Items: []} is valid and common', + withoutEmptyMembers({ RemoveHeadersConfig: { Quantity: 0, Items: [] } }), + { RemoveHeadersConfig: { Quantity: 0, Items: [] } }, +); +t( + 'false, 0, null and empty string are kept', + withoutEmptyMembers({ a: false, b: 0, c: null, d: '' }), + { a: false, b: 0, c: null, d: '' }, +); +t( + 'array elements are recursed into but never removed', + withoutEmptyMembers({ Items: [{ Header: 'X', Sub: {} }, {}] }), + { Items: [{ Header: 'X' }, {}] }, +); +t( + 'the custom-headers list the script builds is untouched', + withoutEmptyMembers({ + CustomHeadersConfig: { + Quantity: 1, + Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], + }, + }), + { + CustomHeadersConfig: { + Quantity: 1, + Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], + }, + }, +); +t('stripping is idempotent', withoutEmptyMembers(stripped), stripped); + +/* ---- the drift comparison: {} and absent must normalise alike ------------ + Round 1's repair stripped children but left `norm({})` as "{}" against + `norm(undefined)` as "null", which reported permanent, unrepairable drift on + the intake form's own path. */ +const norm = (o) => { + const v = withoutEmptyMembers(o); + return JSON.stringify(isEmptyObject(v) ? null : (v ?? null)); +}; +t('norm({}) equals norm(undefined)', norm({}), norm(undefined)); +t('norm({CorsConfig:{}}) equals norm({})', norm({ CorsConfig: {} }), norm({})); +t( + 'but a real difference still differs', + norm({ a: 1 }) === norm({ a: 2 }), + false, +); + +/* ---- emptyObjectPaths, the post-condition ------------------------------- */ +t( + 'reports the incident path', + emptyObjectPaths({ SecurityHeadersConfig: LIVE_SECURITY_HEADERS }), + ['SecurityHeadersConfig.ContentSecurityPolicy'], +); +t( + 'reports round 2s deeper path', + emptyObjectPaths({ CorsConfig: { AccessControlExposeHeaders: {} } }), + ['CorsConfig.AccessControlExposeHeaders'], +); +t( + 'reports an empty object inside an array, with its index', + emptyObjectPaths({ Items: [{ Header: 'X' }, {}] }), + ['Items[1]'], +); +t( + 'reports every one, not just the first', + emptyObjectPaths({ a: {}, b: { c: {} } }), + ['a', 'b.c'], +); +t('silent on an empty array', emptyObjectPaths({ a: [] }), []); +t( + 'silent on null, undefined and primitives', + emptyObjectPaths({ a: null, b: undefined, c: 1, d: 'x', e: true }), + [], +); +t('names the root when the whole config is empty', emptyObjectPaths({}), [ + '(root)', +]); + +/* ---- the invariant the two functions exist to hold together ------------- */ +t( + 'THE INVARIANT: nothing survives the strip that the assertion would report', + emptyObjectPaths( + withoutEmptyMembers({ + Name: 'adr-sml-pdf-noindex', + SecurityHeadersConfig: LIVE_SECURITY_HEADERS, + CorsConfig: { AccessControlExposeHeaders: {} }, + ServerTimingHeadersConfig: {}, + CustomHeadersConfig: { + Quantity: 1, + Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], + }, + }), + ), + [], +); + +if (failures.length) { + console.error( + `policy-shapes: ${failures.length} FAILED\n - ${failures.join('\n - ')}`, + ); + process.exit(1); +} +console.log(`policy-shapes: ${pass} of ${pass} cases pass`);