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
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Applies the four distribution changes the site needs, as one reviewable
|
||||
* Applies the five distribution changes the site needs, as one reviewable
|
||||
* transaction. `docs/09-cutover-runbook.md` Part 3 is what calls it.
|
||||
*
|
||||
* 1. FunctionAssociations on the default behaviour -> `router.js`, viewer
|
||||
@@ -12,6 +12,12 @@
|
||||
* 4. A `*.pdf` cache behaviour carrying a response-headers policy that adds
|
||||
* `X-Robots-Tag: noindex`, so the bio PDF is not indexed as a duplicate of
|
||||
* `/bio/`. `docs/06`'s checklist item carries the reasoning.
|
||||
* 5. A custom origin request policy on `/api/*` forwarding
|
||||
* `CloudFront-Viewer-Address` — the only address CloudFront generates and
|
||||
* overwrites, so the only one that could ever support a per-IP measure.
|
||||
* ⚠️ THE ONLY ITEM HERE THAT REPLACES RATHER THAN ADDS, and it replaces
|
||||
* the policy on the path the intake form posts to. `docs/09` Part 3's
|
||||
* verification block runs after it, not optionally.
|
||||
*
|
||||
* ⚠️ DRY RUN BY DEFAULT. It prints what it would change and exits 0 without
|
||||
* calling `update-distribution`. `--apply` is the only thing that writes, and it
|
||||
@@ -34,6 +40,7 @@
|
||||
* node infra/cloudfront/configure.mjs ... --apply
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name) => {
|
||||
@@ -574,6 +581,303 @@ if (!defaultRhpId) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 5. CloudFront-Viewer-Address on /api/* -----------------------------
|
||||
⚠️ THIS IS THE ONE CHANGE IN THIS FILE THAT CAN BREAK A LIVE FORM, AND THE
|
||||
VERIFY-AND-ROLLBACK BLOCK IN `docs/09` PART 3 IS NOT OPTIONAL AFTER IT.
|
||||
Everything else here ADDS. This one REPLACES the origin request policy on the
|
||||
behaviour that carries real legal inquiries: get the header set wrong and
|
||||
every submission redirects to /contact/could-not-send/, which looks like a
|
||||
browser problem and is not.
|
||||
|
||||
WHY A WHITELIST, WHICH IS NOT THE OBVIOUS CHOICE. The wanted forwarding is
|
||||
"every viewer header except Host, plus CloudFront-Viewer-Address", and NO
|
||||
ORIGIN REQUEST POLICY EXPRESSES IT. ⚠️ THAT IS A CLAIM ABOUT ORIGIN REQUEST
|
||||
POLICIES, NOT ABOUT AWS, AND IT SAID "AWS has no behaviour that expresses it"
|
||||
FOR ONE ROUND — the shape `CLAUDE.md` names: "no mechanism can X" is a claim
|
||||
about every mechanism, including the ones you did not enumerate. **The one
|
||||
not enumerated: a viewer-request CloudFront Function on /api/* that copies
|
||||
`event.viewer.ip` into a custom header, leaving the managed policy in place.**
|
||||
That removes this section's entire failure class — nothing can be dropped
|
||||
because nothing is re-listed — at the cost of a second function on a path
|
||||
whose "no function association" comment is load-bearing for a different
|
||||
reason (a 301 would turn the POST into a GET; a header-only function would
|
||||
not). It is not built because Pouya's ruling names an origin request policy;
|
||||
it is written down so the choice is visible rather than implied.
|
||||
|
||||
Derived from the API's own enum, not recalled:
|
||||
|
||||
allViewer - viewer headers only, Host included
|
||||
allExcept - viewer headers minus a list; the list is
|
||||
an EXCLUSION, so nothing can be added
|
||||
allViewerAndWhitelistCloudFront - viewer headers PLUS CloudFront headers,
|
||||
and "viewer headers" includes Host, which
|
||||
403s at API Gateway. `docs/09` warns
|
||||
against exactly this one
|
||||
whitelist - only the listed headers, and CloudFront
|
||||
headers may be listed
|
||||
|
||||
CloudFront-generated headers exist in none of the "allViewer*" sets except
|
||||
the one that also drags Host along. So `whitelist` is the only shape left,
|
||||
and the cost of it is that the list below is now load-bearing: a header
|
||||
omitted here is a header the handler never sees.
|
||||
|
||||
⚠️ THE LIST IS THE HANDLER'S OWN READS, AND NOTHING ELSE. `handler.mjs` reads
|
||||
exactly four headers — content-type, origin, referer, user-agent. Adding a
|
||||
fifth read there without adding it here is silent: the value simply arrives
|
||||
undefined. The check PRINTS THE NAMES, so it can be compared to the list
|
||||
above rather than counted:
|
||||
|
||||
grep -o "headerOf(event, '[a-z-]*'" backend/intake/handler.mjs \\
|
||||
| sed "s/.*'\\(.*\\)'/\\1/" | sort
|
||||
|
||||
⚠️ `grep -n "headerOf(event"` WAS PRESCRIBED HERE AND IN TWO DOCUMENTS AND IT
|
||||
RETURNS FIVE, NOT FOUR — it matches `function headerOf(event, name)`, its own
|
||||
definition. An operator comparing 5 against a documented 4 concludes the
|
||||
handler grew a read it did not grow. A count is the wrong instrument when the
|
||||
names are what the whitelist has to match.
|
||||
|
||||
⚠️ COOKIES AND QUERY STRINGS STAY `all`, MATCHING THE MANAGED POLICY THIS
|
||||
REPLACES. The site sets no cookies and the endpoint reads no query string, so
|
||||
`none` would be tidier and is deliberately not used: the only reviewable
|
||||
delta should be the header set. A second change hidden inside this one is how
|
||||
a rollback stops being a rollback.
|
||||
|
||||
WHAT IT BUYS, AND IT IS NOT USED YET. `requestContext.http.sourceIp` behind
|
||||
this behaviour is a CloudFront edge, so the stored value identifies AWS
|
||||
rather than the sender, and `x-forwarded-for` is client-forgeable — see
|
||||
`viewerIp()`. `CloudFront-Viewer-Address` is generated and overwritten by
|
||||
CloudFront, so it is the one trustworthy value. Pouya's ruling of 2026-09-04:
|
||||
forward it so per-IP measures become possible later, MEASURED AND NOT YET
|
||||
ACTED ON. The handler is unchanged and still stores the edge address.
|
||||
|
||||
⚠️ SO DO NOT "FIX" `viewerIp()` TO READ THIS HEADER AS A FOLLOW-UP. What the
|
||||
record holds is published on `/legal/privacy/`, field by field; changing the
|
||||
stored value changes a disclosure, and `docs/09` §7.2's decision table is the
|
||||
procedure for that. Forwarding a header is infrastructure. Storing it is a
|
||||
privacy-policy edit. */
|
||||
const ORP_NAME = 'adr-sml-api-viewer-address';
|
||||
/* Sorted, because the drift check below compares this list to what CloudFront
|
||||
returns and an ordering difference would read as a drift. */
|
||||
const ORP_HEADERS = [
|
||||
'CloudFront-Viewer-Address',
|
||||
'Content-Type',
|
||||
'Origin',
|
||||
'Referer',
|
||||
'User-Agent',
|
||||
];
|
||||
|
||||
/**
|
||||
* ⚠️ THE WHITELIST IS CHECKED AGAINST THE HANDLER'S SOURCE, NOT AGAINST A
|
||||
* COMMENT. `ORP_HEADERS` is a second copy of a fact `backend/intake/handler.mjs`
|
||||
* owns, and this repository's rule is that a duplicated fact needs a mechanism —
|
||||
* `npm run check:intake` exists for exactly this shape. Until 2026-09-04 the
|
||||
* only thing keeping the two in step was a comment plus a grep an operator was
|
||||
* asked to run by eye, on the one change that can break a live intake form.
|
||||
*
|
||||
* A header the handler reads and this list omits is silently `undefined` at run
|
||||
* time. So: read the handler, extract every `headerOf(event, '<name>')`, and
|
||||
* refuse to proceed if any of them is missing here. **Missing FILE is a skip,
|
||||
* not a throw** — `configure.mjs` must stay runnable from a checkout that does
|
||||
* not carry `backend/`, and sections 1-3 have already staged their work.
|
||||
*/
|
||||
function handlerHeaderReads() {
|
||||
const path = new URL('../../backend/intake/handler.mjs', import.meta.url)
|
||||
.pathname;
|
||||
let src;
|
||||
try {
|
||||
src = readFileSync(path, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
[...src.matchAll(/headerOf\(event,\s*'([a-z-]+)'/g)].map((m) => m[1]),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function findApiOriginRequestPolicy() {
|
||||
const res = aws([
|
||||
'cloudfront',
|
||||
'list-origin-request-policies',
|
||||
'--type',
|
||||
'custom',
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
const items = res?.OriginRequestPolicyList?.Items ?? [];
|
||||
return (
|
||||
items.find(
|
||||
(i) => i.OriginRequestPolicy.OriginRequestPolicyConfig.Name === ORP_NAME,
|
||||
)?.OriginRequestPolicy ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/* Read from `cfg`, not from `behaviours`: section 3 may have just staged this
|
||||
behaviour in the same run, and it must be reachable either way. */
|
||||
const apiBehaviour = (cfg.CacheBehaviors?.Items ?? []).find(
|
||||
(b) => b.PathPattern === PATH_PATTERN,
|
||||
);
|
||||
|
||||
if (!apiBehaviour) {
|
||||
/* Unreachable in practice — section 3 either found it or pushed it — so if it
|
||||
fires, something above changed. Skip rather than throw, for the reason
|
||||
section 4 gives: sections 1-3 have already staged their mutations. */
|
||||
skipped.push(
|
||||
`${PATH_PATTERN} / ${ORP_NAME} — no ${PATH_PATTERN} cache behaviour to attach it to`,
|
||||
);
|
||||
} else {
|
||||
const reads = handlerHeaderReads();
|
||||
if (reads === null) {
|
||||
skipped.push(
|
||||
`${PATH_PATTERN} / ${ORP_NAME} — backend/intake/handler.mjs is not in this checkout, so the whitelist could not be checked against the handler's own reads`,
|
||||
);
|
||||
} else {
|
||||
const lower = ORP_HEADERS.map((h) => h.toLowerCase());
|
||||
const missing = reads.filter((h) => !lower.includes(h));
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`${ORP_NAME} would NOT forward ${missing.length} header(s) the handler reads: ` +
|
||||
`${missing.join(', ')}.\n` +
|
||||
` handler reads : ${reads.join(', ')}\n` +
|
||||
` whitelist : ${lower.join(', ')}\n` +
|
||||
`Every submission would validate short and land on /contact/could-not-send/, ` +
|
||||
`which reads to the inquirer as their own browser. Add the header to ORP_HEADERS ` +
|
||||
`and re-run. This is checked here rather than by eye because the grep that was ` +
|
||||
`prescribed for it returned five lines for four reads.`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`· whitelist covers all ${reads.length} headers the handler reads (${reads.join(', ')})`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingOrp = findApiOriginRequestPolicy();
|
||||
let orpId = existingOrp?.Id ?? null;
|
||||
|
||||
const wantedOrp = {
|
||||
HeadersConfig: {
|
||||
HeaderBehavior: 'whitelist',
|
||||
Headers: { Quantity: ORP_HEADERS.length, Items: ORP_HEADERS },
|
||||
},
|
||||
CookiesConfig: { CookieBehavior: 'all' },
|
||||
QueryStringsConfig: { QueryStringBehavior: 'all' },
|
||||
};
|
||||
|
||||
if (existingOrp) {
|
||||
const have = existingOrp.OriginRequestPolicyConfig;
|
||||
const norm = (o) => JSON.stringify(o ?? null);
|
||||
/* Compare the header ITEMS as a sorted set rather than the whole
|
||||
HeadersConfig object: CloudFront echoes `Quantity` back and a list that
|
||||
differs only in order is the same forwarding rule. A drift report that
|
||||
fires on ordering is a drift report nobody reads twice. */
|
||||
const haveHeaders = [...(have.HeadersConfig?.Headers?.Items ?? [])].sort();
|
||||
const drift = [];
|
||||
if (have.HeadersConfig?.HeaderBehavior !== 'whitelist')
|
||||
drift.push([
|
||||
'HeaderBehavior',
|
||||
have.HeadersConfig?.HeaderBehavior,
|
||||
'whitelist',
|
||||
]);
|
||||
if (norm(haveHeaders) !== norm([...ORP_HEADERS].sort()))
|
||||
drift.push(['Headers', norm(haveHeaders), norm(ORP_HEADERS)]);
|
||||
for (const k of ['CookiesConfig', 'QueryStringsConfig']) {
|
||||
if (norm(have[k]) !== norm(wantedOrp[k]))
|
||||
drift.push([k, norm(have[k]), norm(wantedOrp[k])]);
|
||||
}
|
||||
if (drift.length) {
|
||||
/* Both sides, same rule as section 4: naming the field does not say which
|
||||
direction to repair in, and here the two directions are "the handler
|
||||
reads a header nobody forwards" and "CloudFront forwards a header
|
||||
nobody reads". Only one of those loses inquiries. */
|
||||
throw new Error(
|
||||
`${ORP_NAME} has DRIFTED from what this script expects on ` +
|
||||
`${drift.length} field(s). ${PATH_PATTERN} is the intake form's path, ` +
|
||||
`so read which way before repairing:\n` +
|
||||
drift
|
||||
.map(([k, a, b]) => ` ${k}\n live : ${a}\n wanted : ${b}`)
|
||||
.join('\n') +
|
||||
`\nReconcile with update-origin-request-policy (it needs the policy's ` +
|
||||
`own ETag), then re-run.` +
|
||||
`\n\nNOTE: in an --apply run this throws AFTER section 4 may already have ` +
|
||||
`created ${PDF_POLICY_NAME}, and BEFORE update-distribution is called — ` +
|
||||
`so a policy can exist that no behaviour references. That is harmless ` +
|
||||
`and self-healing: the next run finds it by name, matches it, and ` +
|
||||
`attaches it. Do not delete it by hand.`,
|
||||
);
|
||||
}
|
||||
console.log(`· origin request policy ${ORP_NAME} exists and matches`);
|
||||
} else if (!APPLY) {
|
||||
console.log(`· would CREATE origin request policy ${ORP_NAME}`);
|
||||
changes.push(
|
||||
`create origin request policy ${ORP_NAME} (whitelist: ${ORP_HEADERS.join(', ')}; cookies all; query strings all)`,
|
||||
);
|
||||
} else {
|
||||
const created = aws([
|
||||
'cloudfront',
|
||||
'create-origin-request-policy',
|
||||
'--origin-request-policy-config',
|
||||
JSON.stringify({
|
||||
Name: ORP_NAME,
|
||||
Comment:
|
||||
'Forwards CloudFront-Viewer-Address plus the four headers the intake handler reads. Replaces Managed-AllViewerExceptHostHeader on /api/*. See infra/cloudfront/configure.mjs section 5.',
|
||||
...wantedOrp,
|
||||
}),
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
orpId = created?.OriginRequestPolicy?.Id;
|
||||
if (!orpId) {
|
||||
throw new Error(
|
||||
`create-origin-request-policy returned no Id for ${ORP_NAME}`,
|
||||
);
|
||||
}
|
||||
changes.push(`created origin request policy ${ORP_NAME} (${orpId})`);
|
||||
}
|
||||
|
||||
if (orpId && apiBehaviour.OriginRequestPolicyId === orpId) {
|
||||
console.log(`· ${PATH_PATTERN} already uses ${ORP_NAME}`);
|
||||
} else if (!APPLY) {
|
||||
console.log(
|
||||
`· would SET ${PATH_PATTERN} OriginRequestPolicyId -> ${ORP_NAME}` +
|
||||
` (from ${apiBehaviour.OriginRequestPolicyId})`,
|
||||
);
|
||||
changes.push(
|
||||
`${PATH_PATTERN} OriginRequestPolicyId ${apiBehaviour.OriginRequestPolicyId} -> ${ORP_NAME}`,
|
||||
);
|
||||
} else {
|
||||
const from = apiBehaviour.OriginRequestPolicyId;
|
||||
apiBehaviour.OriginRequestPolicyId = orpId;
|
||||
changes.push(
|
||||
`${PATH_PATTERN} OriginRequestPolicyId ${from} -> ${orpId} (${ORP_NAME})`,
|
||||
);
|
||||
/* Printed at the moment of the change, not only in the runbook, because the
|
||||
operator who needs it most is the one who did not read Part 3 first.
|
||||
|
||||
⚠️ IT NAMED THE OLD ID AS `Managed-AllViewerExceptHostHeader` WITHOUT
|
||||
CHECKING, and printed an empty string when the behaviour carried no
|
||||
policy at all — an "id" an operator would paste into a rollback. It now
|
||||
says only what it read, and says so when it read nothing.
|
||||
|
||||
⚠️ AND IT SAID "and re-apply", WHICH NAMES THIS SCRIPT. Re-running with
|
||||
--apply RE-ATTACHES the whitelist: section 5 converges forward and does
|
||||
not know a revert from a first run (they are byte-identical in the
|
||||
config). The rollback is a direct `update-distribution`, and the runbook
|
||||
says so in the sentence under its code block; this line no longer
|
||||
contradicts it. */
|
||||
console.log(
|
||||
from
|
||||
? ` ↩ ROLLBACK for ${PATH_PATTERN}: PUT OriginRequestPolicyId back to ${from}` +
|
||||
`${from === allViewerExceptHost ? ' (Managed-AllViewerExceptHostHeader)' : ''}` +
|
||||
' with update-distribution --if-match. Do NOT re-run this script to' +
|
||||
' roll back — it would re-attach the whitelist.'
|
||||
: ` ↩ ROLLBACK for ${PATH_PATTERN}: the behaviour carried NO origin request` +
|
||||
' policy before this change. Remove the field with' +
|
||||
' update-distribution --if-match; do NOT re-run this script.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
/* Skips print under their own heading and are NOT counted as changes — see the
|
||||
comment on `skipped`. A skip means section 4 did nothing and the PDF is
|
||||
@@ -583,14 +887,17 @@ if (skipped.length) {
|
||||
console.log(`⚠ ${skipped.length} thing(s) SKIPPED, not changed:`);
|
||||
for (const k of skipped) console.log(` ! ${k}`);
|
||||
console.log(' Sections 1-3 are unaffected. Investigate before relying on');
|
||||
console.log(` ${PDF_PATTERN} carrying X-Robots-Tag.`);
|
||||
console.log(
|
||||
` ${PDF_PATTERN} carrying X-Robots-Tag, or on ${PATH_PATTERN} forwarding`,
|
||||
);
|
||||
console.log(' CloudFront-Viewer-Address — the skip above says which.');
|
||||
console.log('');
|
||||
}
|
||||
if (changes.length === 0) {
|
||||
console.log(
|
||||
skipped.length
|
||||
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all four.'
|
||||
: 'NOTHING TO CHANGE — the distribution already carries all four.',
|
||||
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all five.'
|
||||
: 'NOTHING TO CHANGE — the distribution already carries all five.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user