feat: price med-arb by phase, attest the conflicts undertaking, and answer the first real spam
Build and deploy / build-and-deploy (push) Failing after 4s
Build and deploy / build-and-deploy (push) Failing after 4s
Pouya's rulings of 2026-09-03 (the last two D20 findings) and 2026-09-04 (the spam observation and four mitigations), in one change set. D20 finding 10 — med-arb is billed BY PHASE, each phase at the rates already published, so /fees/'s "Every figure is on this page" is true as written rather than narrowed. FEES.medArb is the single source; docs/07 §Med-arb carries the rule INTERIM against R5, and R5 now carries it back, because a derived price moves silently when a rate moves. D20 finding 13 — conduct undertaking (g), attested 2026-09-03, published as his wording verbatim on /legal/privacy/ and /contact/. The clause that raised the finding promised to DISCLOSE a conflicts check's outcome, which the attestation does not cover; it is struck. D20 now partitions 17 fixed / 2 refuted / 1 owed. Spam, 2026-09-04 — recorded in docs/05 §Observed abuse with the date and signature. A second honeypot (a decoy checkbox, own class, `hidden`, a label that tells a human not to tick it) and scoring that LABELS and never rejects: nothing is dropped, nothing new is stored, and only the operator notification changes. Q65 opens the WAF cost call. The timing floor could not be built: there is no timing check and never has been. docs/05 carries it struck, and every mechanism that would give a real per-visitor clock breaks zero-JS, handler-and-form-only, or D1. Q66. configure.mjs gains section 5 — a custom origin request policy forwarding CloudFront-Viewer-Address on /api/*. Written, dry-run against the live distribution, NOT applied. It reads the handler's own header reads and refuses to run if the whitelist omits one. And reading the live account to do it found four AGENTS.md §7 rows saying the intake backend was undeployed, two days after it went live — corrected against get-function-configuration, get-routes, get-stage, get-policy and the deployed zip, which was downloaded and read. Review: adversarial-reviewer only (claims-auditor is D20's cutover pass and has run). Round 1 five lenses, 56 findings, 7 blocking, 4 refuted by an independent refuter; round 2 four lenses, 36 findings, 33 of them defects in round 1's own repairs. Stopped at two per D19. Gates, exit status read for each: check 0 · build 0 (23 pages) · check:claims 0 · check:intake 0 · og:proof 0 · lint 0 · spam-score.test 39/39 with 6/6 mutations killed · router.test 30/30 · minifier grep 1 (clean) · lighthouse 0, no category below 95 · configure.mjs dry run 0, nothing written. Nothing deployed and nothing applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
02739adac9
commit
3c3ba5dc6e
+269
-26
@@ -309,7 +309,7 @@ status, not the absence of an error.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Apply the four distribution changes
|
||||
## Part 3 — Apply the five distribution changes
|
||||
|
||||
One script, `infra/cloudfront/configure.mjs`, because the alternative is
|
||||
hand-editing a 300-line JSON document and posting it back with an `IfMatch` ETag.
|
||||
@@ -328,13 +328,15 @@ Part 0.3 records is exactly:
|
||||
resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
|
||||
resolved Managed-AllViewerExceptHostHeader = b689b0a8-53d0-40ab-baf2-68738e2966ac
|
||||
|
||||
6 change(s) to distribution E1OK7G98KNKUTA (ETag …):
|
||||
8 change(s) to distribution E1OK7G98KNKUTA (ETag …):
|
||||
+ DefaultCacheBehavior.FunctionAssociations viewer-request -> arn:…:function/adr-sml-router
|
||||
+ CustomErrorResponses += 404 -> /404.html with status 404
|
||||
+ Origins += intake-api -> …execute-api… (https-only, TLSv1.2)
|
||||
+ CacheBehaviors += /api/* -> intake-api, CachingDisabled, AllViewerExceptHostHeader, POST allowed
|
||||
+ create response-headers policy adr-sml-pdf-noindex (SecurityHeadersConfig cloned from … + X-Robots-Tag: noindex)
|
||||
+ CacheBehaviors += *.pdf -> <s3-origin>, default cache policy, adr-sml-pdf-noindex (policy id created in the same --apply pass)
|
||||
+ create origin request policy adr-sml-api-viewer-address (whitelist: CloudFront-Viewer-Address, Content-Type, Origin, Referer, User-Agent; cookies all; query strings all)
|
||||
+ /api/* OriginRequestPolicyId b689b0a8-… -> adr-sml-api-viewer-address
|
||||
|
||||
DRY RUN — nothing was sent. Re-run with --apply to write it.
|
||||
```
|
||||
@@ -348,11 +350,150 @@ behaviour in one call — do not run it twice.** The dry run reports both change
|
||||
either way; one that listed only the policy would hide the half that touches a
|
||||
distribution serving 23 pages.
|
||||
|
||||
Fewer than six changes means part of this is already done — read which lines are
|
||||
prefixed `·` (already present) and carry on. **On the live distribution as at
|
||||
2026-09-03, changes 1–3 are applied and you should see exactly the last two.**
|
||||
More than six, or a different set, means the distribution is not in the state 0.3
|
||||
recorded: stop and re-read it.
|
||||
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 `·`
|
||||
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
|
||||
2026-09-04 the dry run returns exactly four `+` lines — the two for section 4
|
||||
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,
|
||||
means the distribution is not in the state 0.3 recorded: stop and re-read it.
|
||||
|
||||
⚠️ **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,
|
||||
and the two look identical in a count.
|
||||
|
||||
🛑 **SECTION 5 IS THE ONLY ONE THAT REPLACES SOMETHING, AND WHAT IT REPLACES IS
|
||||
ON THE INTAKE FORM'S PATH.** Sections 1–4 add. Section 5 swaps the origin request
|
||||
policy on `/api/*` from `Managed-AllViewerExceptHostHeader` to a **whitelist** of
|
||||
five headers, because AWS has no behaviour meaning "all viewer headers except
|
||||
Host, plus a CloudFront header" — `allExcept` can only subtract, and
|
||||
`allViewerAndWhitelistCloudFront` drags `Host` along and 403s at API Gateway.
|
||||
Whitelisting is therefore forced, and the cost is that **a header missing from
|
||||
that list is a header the handler never sees.** The list is the handler's four
|
||||
`headerOf` reads plus `CloudFront-Viewer-Address`. The check prints the names,
|
||||
so they can be compared to the whitelist rather than counted:
|
||||
|
||||
```bash
|
||||
grep -o "headerOf(event, '[a-z-]*'" backend/intake/handler.mjs \
|
||||
| sed "s/.*'\(.*\)'/\1/" | sort
|
||||
```
|
||||
|
||||
**Expect** exactly `content-type`, `origin`, `referer`, `user-agent`.
|
||||
⚠️ **`grep -n "headerOf(event"` WAS PRESCRIBED HERE AND RETURNS FIVE** — it
|
||||
matches `function headerOf(event, name)`, the definition itself — so an operator
|
||||
comparing it against a documented "four" concludes the handler grew a read.
|
||||
|
||||
**The failure mode is not an error.** Every submission would validate short and
|
||||
redirect to `/contact/could-not-send/` — a real inquirer would read it as their
|
||||
own browser misbehaving, and nothing would appear in a log as a fault. So the
|
||||
block below is **not optional after an `--apply` that includes change 8**, and there are
|
||||
**three** of them. The first is Part 7.1's probe with its output read differently
|
||||
— **not "unchanged", which this said for one round**: §7.1 pipes into `head -12`
|
||||
and reads the status by eye, while these read curl's own exit status and count
|
||||
the `location` separately.
|
||||
|
||||
**Run all three, in this order, and each answers a different question:**
|
||||
|
||||
| # | probe | what only it can tell you |
|
||||
|---|---|---|
|
||||
| 1 | `Origin` + body | `Origin` is still forwarded — a **403** means it is not |
|
||||
| 2 | `Referer`, no `Origin` | the Firefox fallback still works — nothing else tests it |
|
||||
| 3 | honeypot value | the **body parsed** — probes 1 and 2 return the same 303 whether it did or not |
|
||||
|
||||
**PROBE 1 — is `Origin` still forwarded?**
|
||||
|
||||
```bash
|
||||
curl -si -X POST "$SITE/api/intake" \
|
||||
-H 'Origin: https://adr.smlcompany.ca' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'probe=1' -o /tmp/api.h
|
||||
echo "curl_exit=$?" # curl's OWN status, on its own line
|
||||
head -1 /tmp/api.h
|
||||
grep -ic '^location: .*could-not-send' /tmp/api.h
|
||||
```
|
||||
|
||||
**Expect** `curl_exit=0`, `HTTP/2 303`, and `1`. A **403** here means the
|
||||
`Origin` header is no longer reaching the handler — i.e. the whitelist dropped
|
||||
it — and the form is broken for everyone.
|
||||
|
||||
**PROBE 2 — the `Referer` fallback, which nothing else tests.** The handler
|
||||
accepts `Referer` when `Origin` is absent (Firefox omits `Origin` on some
|
||||
same-origin form navigations), so a whitelist that forwarded `Origin` and dropped
|
||||
`Referer` passes probe 1 and fails for exactly those users:
|
||||
|
||||
```bash
|
||||
curl -si -X POST "$SITE/api/intake" \
|
||||
-H 'Referer: https://adr.smlcompany.ca/contact/' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'company_website=probe' -o /tmp/api3.h
|
||||
echo "curl_exit=$?"
|
||||
head -1 /tmp/api3.h
|
||||
grep -ic '^location: .*contact/received' /tmp/api3.h
|
||||
```
|
||||
|
||||
**Expect** `curl_exit=0`, `303` and `1` `[verified against production 2026-09-04
|
||||
— it returns 303 today, on the managed policy]`. A **403** means `Referer` is not
|
||||
being forwarded.
|
||||
|
||||
🛑 **PROBE 3, AND NEITHER OF THE FIRST TWO CAN REPLACE IT: THEY CANNOT FAIL IN THE
|
||||
INTERESTING DIRECTION.** `303 →
|
||||
could-not-send` is what the handler returns **both** when it parsed the body and
|
||||
found an empty submission **and** when `parseBody` threw because
|
||||
`Content-Type` never arrived. Two opposite outcomes, one status, one location —
|
||||
so a dropped `Content-Type` reads as a pass. This probe separates them, and
|
||||
**writes nothing and sends nothing**:
|
||||
|
||||
```bash
|
||||
curl -si -X POST "$SITE/api/intake" \
|
||||
-H 'Origin: https://adr.smlcompany.ca' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'company_website=probe' -o /tmp/api2.h
|
||||
echo "curl_exit=$?"
|
||||
head -1 /tmp/api2.h
|
||||
grep -ic '^location: .*contact/received' /tmp/api2.h
|
||||
```
|
||||
|
||||
**Expect** `curl_exit=0`, `HTTP/2 303`, and `1` — location
|
||||
`/contact/received/`, **not** `could-not-send`. That is the honeypot branch: it
|
||||
is reached **only if the body parsed**, and it returns before validation, before
|
||||
any DynamoDB write and before any SES send, so it leaves no record and sends no
|
||||
email. `could-not-send` here means the body did not parse — `Content-Type` is
|
||||
missing from the whitelist. **Roll back.**
|
||||
|
||||
⚠️ **IT DEPENDS ON THE HONEYPOT'S NAME** (`company_website`, `fields.mjs`). If
|
||||
that is ever renamed, this probe degrades to the `could-not-send` branch — which
|
||||
reads as a failure and starts an investigation, not as a pass. That direction is
|
||||
the safe one; keep it that way if you change the probe.
|
||||
|
||||
**ROLLBACK, and it is one field.** Do not debug a broken intake form in place:
|
||||
|
||||
```bash
|
||||
# ⚠️ THIS RETURNS THE ID THE BEHAVIOUR HAS NOW — which, if change 8 applied, is
|
||||
# the whitelist you are rolling back FROM, not the value to restore. The value to
|
||||
# restore is the managed id on the line below. Run this to confirm which state
|
||||
# you are in, then PUT the managed id back with
|
||||
# update-distribution --if-match. ⚠️ NOT by re-running configure.mjs: section 5
|
||||
# converges FORWARD and cannot tell a deliberate revert from a first run — the
|
||||
# two are byte-identical in the config — so --apply would re-attach the
|
||||
# whitelist and put the form back in the state you are rolling back from.
|
||||
aws cloudfront get-distribution-config --id "$DIST_ID" \
|
||||
--query 'DistributionConfig.CacheBehaviors.Items[?PathPattern==`/api/*`].OriginRequestPolicyId'
|
||||
# Managed-AllViewerExceptHostHeader = b689b0a8-53d0-40ab-baf2-68738e2966ac
|
||||
```
|
||||
|
||||
Set that behaviour's `OriginRequestPolicyId` back to
|
||||
`b689b0a8-53d0-40ab-baf2-68738e2966ac` and `update-distribution` with the current
|
||||
ETag. `configure.mjs` prints the same id on the line it changes, prefixed `↩`, at
|
||||
the moment it changes it.
|
||||
|
||||
⚠️ **THE HANDLER STILL STORES THE EDGE ADDRESS AFTER THIS.** Forwarding the
|
||||
header does not change what is recorded, and it must not be made to as a
|
||||
follow-up edit: what the record holds is published field by field on
|
||||
`/legal/privacy/`, so storing `CloudFront-Viewer-Address` is a **disclosure**
|
||||
change governed by §7.2's decision table, not a code tidy. Pouya's ruling of
|
||||
2026-09-04 is *measured, not yet acted on*.
|
||||
|
||||
⚠️ **AND `adr-sml-pdf-noindex` IS RECONCILED ON EVERY RUN, NOT ONLY CREATED.** A
|
||||
response-headers policy **replaces** rather than merges, so the PDF policy has to
|
||||
@@ -382,6 +523,18 @@ aws cloudfront get-distribution-config --id "$DIST_ID" \
|
||||
would turn the form's POST into a GET and drop the body), and `*.pdf` → the S3
|
||||
origin **with an `RHP` id and `Fn2: ["viewer-request"]`**; two origins.
|
||||
|
||||
⚠️ **THAT QUERY DOES NOT PROJECT `OriginRequestPolicyId`, SO IT CANNOT SEE
|
||||
CHANGE 8.** Read it separately rather than concluding anything from its absence:
|
||||
|
||||
```bash
|
||||
aws cloudfront get-distribution-config --id "$DIST_ID" \
|
||||
--query 'DistributionConfig.CacheBehaviors.Items[].{P:PathPattern,ORP:OriginRequestPolicyId}'
|
||||
```
|
||||
|
||||
**Expect** `/api/*` carrying the **`adr-sml-api-viewer-address`** id — *not*
|
||||
`b689b0a8-53d0-40ab-baf2-68738e2966ac`, which is the managed policy it replaced
|
||||
and is what a rollback restores.
|
||||
|
||||
**Then verify the header actually arrives, because the config landing is not the
|
||||
same fact:**
|
||||
|
||||
@@ -442,16 +595,40 @@ after 8.4, when both halves are true at once.
|
||||
|
||||
### 5.1 Package
|
||||
|
||||
🛑 **THREE FILES SINCE 2026-09-04, AND THE ZIP FOLLOWS NO IMPORT.**
|
||||
`handler.mjs` imports both `./fields.mjs` and `./spam-score.mjs`; a zip missing
|
||||
either fails at cold start with `Runtime.ImportModuleError` and every submission
|
||||
then 500s. **The list is now derived from the directory** — `ls *.mjs` minus the
|
||||
tests — in this step and in 5.5, so a new module is packaged without editing
|
||||
anything. It was typed out in both until 2026-09-04, and this banner still said
|
||||
so, fifteen lines above the paragraph that says otherwise.
|
||||
|
||||
```bash
|
||||
rm -f /tmp/intake.zip
|
||||
(cd backend/intake && zip -q -X /tmp/intake.zip handler.mjs fields.mjs)
|
||||
(cd backend/intake \
|
||||
&& echo "packaging: $(ls *.mjs | grep -v '\.test\.' | tr '\n' ' ')" \
|
||||
&& zip -q -X /tmp/intake.zip $(ls *.mjs | grep -v '\.test\.'))
|
||||
unzip -l /tmp/intake.zip
|
||||
```
|
||||
|
||||
**Expect:** exactly two entries, `handler.mjs` and `fields.mjs`, **≈ 25.7 KB
|
||||
uncompressed and ≈ 10.8 KB zipped** `[measured 2026-09-01]`. Both at the zip root —
|
||||
`handler.mjs` imports `./fields.mjs`, so a nested directory breaks the import at
|
||||
cold start.
|
||||
⚠️ **THE LIST IS SUBSTITUTED DIRECTLY, NOT HELD IN A VARIABLE, AND THAT IS NOT
|
||||
STYLE.** A first version read `MODULES=$(ls …)` then `zip … $MODULES`. **In zsh
|
||||
that packages ONE file whose name is all three joined by newlines** — zsh does
|
||||
not word-split parameter expansions, only command substitutions — so it fails on
|
||||
the shell this project is actually operated from while working in bash.
|
||||
`CLAUDE.md` names this trap; it was reintroduced here and caught by running the
|
||||
block in both shells rather than by reading it.
|
||||
|
||||
**Expect:** exactly three entries — `handler.mjs`, `fields.mjs`,
|
||||
`spam-score.mjs` — **42,604 bytes uncompressed and 18,462 zipped**
|
||||
`[measured 2026-09-04]`. All three at the zip root: the imports are `./`-relative,
|
||||
so a nested directory breaks them at cold start. *(This read "two entries, ≈ 25.7
|
||||
KB / ≈ 10.8 KB" `[measured 2026-09-01]`, before the scorer existed.)*
|
||||
|
||||
⚠️ **`spam-score.test.mjs` IS NOT IN THE ZIP AND MUST NOT BE.** Run it at a
|
||||
keyboard — `node backend/intake/spam-score.test.mjs`, **39 of 39** — before
|
||||
packaging. It is the only check on the scorer, whose failure mode is labelling
|
||||
real inquiries rather than throwing.
|
||||
|
||||
### 5.2 Configuration first, code second
|
||||
|
||||
@@ -500,11 +677,22 @@ aws lambda get-function-configuration --function-name "$FN" \
|
||||
--query '{CodeSize:CodeSize,Runtime:Runtime,Update:LastUpdateStatus,Modified:LastModified}'
|
||||
```
|
||||
|
||||
**Expect:** `CodeSize` **≈ 10,800** (up from 1,527), `Update: Successful`.
|
||||
⚠️ **`CodeSize` is the ZIP, not the source.** This line said "around 23,000",
|
||||
which was 5.1's uncompressed figure applied to a different quantity — an
|
||||
operator seeing `10819` against an expectation of 23,000 would reasonably
|
||||
conclude the wrong artefact went up.
|
||||
**Expect** `Update: Successful`, and a `CodeSize` that says **which path you
|
||||
took** — it is the ZIP, not the source:
|
||||
|
||||
| path | expected `CodeSize` |
|
||||
|---|---|
|
||||
| 5.1's plain three-file zip | **≈ 18,462** |
|
||||
| 5.5's bundled variant | **low single-digit MB** — it was **3,307,021** on 2026-09-02 `[measured 2026-09-04 — get-function-configuration]` |
|
||||
|
||||
🛑 **5.5 IS THE PATH THAT WAS ACTUALLY TAKEN AT CUTOVER.** The live function
|
||||
carries the bundled zip, so **a redeploy that runs 5.1 and stops would replace it
|
||||
with an unbundled one and reintroduce the `Runtime.ImportModuleError` 5.5 exists
|
||||
to fix.** Run 5.4 after 5.3, every time, and follow it to 5.5 if it fires.
|
||||
|
||||
*(This line said "≈ 10,800", and before that "around 23,000" — 5.1's uncompressed
|
||||
figure applied to a different quantity. Both were written against the unbundled
|
||||
path, which is not the one in production.)*
|
||||
|
||||
### 5.4 Prove it loads, without writing anything
|
||||
|
||||
@@ -545,16 +733,36 @@ Versions are resolved from the registry at run time rather than pinned in this
|
||||
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.
|
||||
|
||||
✅ **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
|
||||
with nothing checking that the two agreed — so a module added to one and not the
|
||||
other would ship from whichever path the operator happened to take. Both now read
|
||||
the directory.
|
||||
|
||||
```bash
|
||||
rm -rf /tmp/intake-bundle && mkdir -p /tmp/intake-bundle
|
||||
cp backend/intake/handler.mjs backend/intake/fields.mjs /tmp/intake-bundle/
|
||||
echo "bundling: $(cd backend/intake && ls *.mjs | grep -v '\.test\.' | tr '\n' ' ')"
|
||||
(cd backend/intake && cp $(ls *.mjs | grep -v '\.test\.') /tmp/intake-bundle/)
|
||||
# ⚠️ ASSERT THE COPY LANDED. A glob that matches nothing makes `cp` fail, `zip`
|
||||
# succeed on an empty set, and `update-function-code` upload a bundle with no
|
||||
# handler — a silent failure that only shows up as 5xx on the live form.
|
||||
test -f /tmp/intake-bundle/handler.mjs || { echo "FATAL: handler.mjs not copied"; exit 1; }
|
||||
echo "copied: $(ls /tmp/intake-bundle/*.mjs | wc -l | tr -d ' ') module(s)"
|
||||
( cd /tmp/intake-bundle \
|
||||
&& npm init -y > /dev/null \
|
||||
&& npm install --omit=dev --no-audit --no-fund \
|
||||
"@aws-sdk/client-dynamodb@$(npm view @aws-sdk/client-dynamodb version)" \
|
||||
"@aws-sdk/client-sesv2@$(npm view @aws-sdk/client-sesv2 version)" )
|
||||
rm -f /tmp/intake.zip
|
||||
( cd /tmp/intake-bundle && zip -qr -X /tmp/intake.zip handler.mjs fields.mjs node_modules package.json )
|
||||
( cd /tmp/intake-bundle && zip -qr -X /tmp/intake.zip $(ls *.mjs) node_modules package.json )
|
||||
unzip -l /tmp/intake.zip | tail -1
|
||||
aws lambda update-function-code --function-name "$FN" --zip-file fileb:///tmp/intake.zip
|
||||
aws lambda wait function-updated --function-name "$FN"
|
||||
@@ -687,9 +895,20 @@ have named the cause — API Gateway's `{"message":"Not Found"}` — is replaced
|
||||
you see it. **Check the route first; it is one command:**
|
||||
`aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].RouteKey'`.
|
||||
|
||||
**403** means the `Origin` header did not arrive — check that the behaviour uses
|
||||
`Managed-AllViewerExceptHostHeader`, because a policy that drops `Origin` turns
|
||||
every real submission into a 403. **500** means Part 6.1 was skipped.
|
||||
**403** means the `Origin` header did not arrive, and **as of 2026-09-04 there
|
||||
are two policies it could be** — read which one the behaviour carries before
|
||||
repairing:
|
||||
|
||||
- **`adr-sml-api-viewer-address`** (Part 3, change 8) — a **whitelist**. If
|
||||
`Origin` is missing from its Headers list, or the list drifted, every real
|
||||
submission 403s. Roll back by PUTting the managed id below with
|
||||
`update-distribution --if-match` — **not** by re-running `configure.mjs`,
|
||||
which converges forward and would re-attach the whitelist.
|
||||
- **`Managed-AllViewerExceptHostHeader`** (`b689b0a8-53d0-40ab-baf2-68738e2966ac`)
|
||||
— what it replaced, and what a rollback restores.
|
||||
|
||||
Either way, a policy that drops `Origin` turns every real submission into a 403.
|
||||
**500** means Part 6.1 was skipped.
|
||||
|
||||
### 7.2 A real submission, from the real form
|
||||
|
||||
@@ -735,8 +954,14 @@ the client sent. The fix, if a usable value is wanted, is a **custom** origin
|
||||
request policy on `/api/*` forwarding `CloudFront-Viewer-Address`, which
|
||||
CloudFront generates and overwrites — not the managed
|
||||
`AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request
|
||||
at API Gateway. That is an infrastructure change and it is deliberately not in
|
||||
this runbook: measure first.
|
||||
at API Gateway. ⚠️ **THAT CHANGE IS NOW IN THIS RUNBOOK — Part 3, change 8, written
|
||||
2026-09-04 on Pouya's ruling and NOT YET APPLIED.** This paragraph said it was
|
||||
*"deliberately not in this runbook: measure first"*, which was true until the
|
||||
ruling and false afterwards. **Forwarding the header does not change what is
|
||||
stored:** `viewerIp()` still records `requestContext.http.sourceIp`, and the
|
||||
decision table above is still the procedure for changing that, because what the
|
||||
record holds is published field by field on `/legal/privacy/`. Measure first
|
||||
still governs the STORING, not the forwarding.
|
||||
|
||||
**Expect** the item, with `ttl` a 10-digit epoch-seconds value. Check it is 24
|
||||
months out — read it, do not assume it:
|
||||
@@ -913,9 +1138,27 @@ Each of these is independent. None of them needs the others undone first.
|
||||
Missing keys go back to 403 and the 404 mapping stops firing; nothing else changes.
|
||||
|
||||
**9.2 Parts 2–3** — re-run `configure.mjs` is *not* a rollback; it is idempotent
|
||||
forward-only. To undo, `get-distribution-config`, remove the
|
||||
forward-only, **and that now matters most for change 8**: section 5 re-attaches
|
||||
the `/api/*` whitelist on the next `--apply`, because a deliberately reverted
|
||||
behaviour and a never-configured one are byte-identical in the config and no
|
||||
detector can separate them. To undo, `get-distribution-config`, remove the
|
||||
`FunctionAssociations` entry / the `404` custom error response / the `/api/*`
|
||||
behaviour and the `intake-api` origin, and `update-distribution --if-match`. Then
|
||||
behaviour and the `intake-api` origin, and `update-distribution --if-match`.
|
||||
|
||||
**Sections 4 and 5 were added after this paragraph and undo the same way:**
|
||||
put `/api/*`'s `OriginRequestPolicyId` back to
|
||||
`b689b0a8-53d0-40ab-baf2-68738e2966ac` (the managed policy) and/or remove the
|
||||
`*.pdf` behaviour, with `update-distribution --if-match`. The two custom policies
|
||||
`adr-sml-api-viewer-address` and `adr-sml-pdf-noindex` can then be deleted with
|
||||
`delete-origin-request-policy` / `delete-response-headers-policy`, each of which
|
||||
**fails while still attached** — the same ordering feature as the function below.
|
||||
⚠️ **Deleting the policies does not prevent re-attachment either** — the next
|
||||
`--apply` simply creates them again by name and attaches them. Nothing in this
|
||||
script can be made to remember a deliberate revert, because a reverted behaviour
|
||||
and a never-configured one are byte-identical in the config. **The rollback holds
|
||||
only until someone runs `configure.mjs --apply` again**; that is a property of a
|
||||
forward-converging script, and the fix if it ever matters is a flag, not a
|
||||
deletion. Then
|
||||
`aws cloudfront delete-function --name adr-sml-router --if-match <etag>`, which
|
||||
fails while the function is still associated — that ordering is a feature.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user