feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Build and deploy / build-and-deploy (push) Failing after 4s

Five items of Pouya's production run, 2026-09-01.

Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.

Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.

Four launch blockers found by reading the running system:
  - handler.mjs wrote pk/sk; the live table's key is submissionId with no
    sort key, so every submission would have failed validation silently
  - the Lambda invoke permission is scoped to the old route path
  - 22 of 23 pages 403 without the router function
  - there was no 404 page; src/pages/404.astro adds it

Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.

Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-09-02 06:52:20 -04:00
co-authored by Claude Opus 5
parent ca1c2524e1
commit bd282aa47d
30 changed files with 3256 additions and 143 deletions
+329
View File
@@ -0,0 +1,329 @@
/**
* Applies the three 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
* request. Without it 22 of 23 pages return S3's AccessDenied XML.
* 2. CustomErrorResponses: 404 -> /404.html with response code 404.
* `docs/04` requires a genuine 404 status; `docs/06` calls a 200 here
* "the single most common misconfiguration in this stack".
* 3. A `/api/*` cache behaviour on a new origin pointing at the HTTP API, so
* the intake form's same-origin POST reaches the handler.
*
* ⚠️ 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
* sends the `IfMatch` ETag it read, so a concurrent console edit fails the call
* rather than being overwritten.
*
* ⚠️ IDEMPOTENT ON PURPOSE. Every change is checked for before it is made, so a
* re-run after a partial failure completes the rest instead of adding a second
* `/api/*` behaviour. Re-running a runbook step is the normal case, not the
* exception.
*
* ⚠️ NO MANAGED POLICY ID IS WRITTEN IN THIS FILE. They are resolved by name
* from the account at run time — `CLAUDE.md`'s rule that a pin is verified
* against the registry rather than recalled applies to an AWS identifier just
* as much as to an npm version, and a wrong cache-policy id here would ship a
* cached POST endpoint.
*
* usage:
* node infra/cloudfront/configure.mjs --dist <id> --api-domain <host> [--function-arn <arn>]
* node infra/cloudfront/configure.mjs ... --apply
*/
import { execFileSync } from 'node:child_process';
const args = process.argv.slice(2);
const flag = (name) => {
const i = args.indexOf(`--${name}`);
return i === -1 ? undefined : args[i + 1];
};
const APPLY = args.includes('--apply');
const DIST = flag('dist');
const API_DOMAIN = flag('api-domain');
const FUNCTION_ARN = flag('function-arn');
if (!DIST || !API_DOMAIN) {
console.error(
'usage: node infra/cloudfront/configure.mjs --dist <distribution-id> ' +
'--api-domain <api-id>.execute-api.<region>.amazonaws.com ' +
'[--function-arn <router-function-arn>] [--apply]',
);
console.error('Values come from AGENTS.md §7.');
process.exit(2);
}
if (/^https?:/.test(API_DOMAIN) || API_DOMAIN.includes('/')) {
console.error(
`--api-domain must be a bare hostname, not a URL: got ${API_DOMAIN}`,
);
process.exit(2);
}
/* stderr is NEVER suppressed and the exit status is always read — the AWS CLI
reports an expired session, a missing permission and a typo'd id all on
stderr with a non-zero status, and swallowing that is how "it failed" becomes
"it found nothing" (CLAUDE.md, from AGENTS.md Q22). */
const aws = (argv) => {
const out = execFileSync('aws', argv, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
maxBuffer: 64 * 1024 * 1024,
});
return out.trim() === '' ? null : JSON.parse(out);
};
const ORIGIN_ID = 'intake-api';
const PATH_PATTERN = '/api/*';
const ERROR_PAGE = '/404.html';
function managedId(kind, name) {
const listCmd = {
cache: ['list-cache-policies', 'CachePolicyList', 'CachePolicy'],
origreq: [
'list-origin-request-policies',
'OriginRequestPolicyList',
'OriginRequestPolicy',
],
}[kind];
const res = aws([
'cloudfront',
listCmd[0],
'--type',
'managed',
'--output',
'json',
]);
const items = res?.[listCmd[1]]?.Items ?? [];
const hit = items.find(
(i) => i[listCmd[2]][`${listCmd[2]}Config`].Name === name,
);
if (!hit) {
throw new Error(
`no managed ${kind} policy named ${name}${items.length} listed. ` +
'Do not substitute an id from memory.',
);
}
return hit[listCmd[2]].Id;
}
const cachingDisabled = managedId('cache', 'Managed-CachingDisabled');
/* AllViewerExceptHostHeader, and the exception is the whole reason: API Gateway
routes on the Host header, so forwarding the viewer's `adr.smlcompany.ca`
makes every request a 403 from the API. It forwards everything else, which is
what carries `Origin` and `Referer` — the handler's CSRF control reads both,
so a policy that dropped them would turn every real submission into a 403. */
const allViewerExceptHost = managedId(
'origreq',
'Managed-AllViewerExceptHostHeader',
);
console.log(`resolved Managed-CachingDisabled = ${cachingDisabled}`);
console.log(
`resolved Managed-AllViewerExceptHostHeader = ${allViewerExceptHost}`,
);
const current = aws([
'cloudfront',
'get-distribution-config',
'--id',
DIST,
'--output',
'json',
]);
const etag = current.ETag;
const cfg = current.DistributionConfig;
if (!etag || !cfg) throw new Error('could not read the distribution config');
const changes = [];
/* ---- 1. viewer-request function on the default behaviour ---------------- */
if (FUNCTION_ARN) {
const fa = cfg.DefaultCacheBehavior.FunctionAssociations ?? { Quantity: 0 };
const existing = (fa.Items ?? []).filter(
(i) => i.EventType === 'viewer-request',
);
if (existing.length === 1 && existing[0].FunctionARN === FUNCTION_ARN) {
console.log(
'· default behaviour already runs this function on viewer-request',
);
} else {
const items = (fa.Items ?? []).filter(
(i) => i.EventType !== 'viewer-request',
);
items.push({ EventType: 'viewer-request', FunctionARN: FUNCTION_ARN });
cfg.DefaultCacheBehavior.FunctionAssociations = {
Quantity: items.length,
Items: items,
};
changes.push(
`DefaultCacheBehavior.FunctionAssociations viewer-request -> ${FUNCTION_ARN}` +
(existing.length ? ` (replacing ${existing[0].FunctionARN})` : ''),
);
}
} else {
console.log('· no --function-arn given, leaving FunctionAssociations alone');
}
/* ---- 2. custom error response ------------------------------------------- */
/* ⚠️ ONLY 404 IS MAPPED, NOT 403, AND THAT IS DELIBERATE. Mapping 403 as well
would swallow two different real failures: a broken bucket policy or OAC
would render as "page not found" on every URL at once, and the intake
handler's Origin refusal (a 403 from the API origin) would come back as a 404
page. Custom error responses are distribution-wide — they cannot be scoped to
one behaviour — so the fix for missing keys is on the S3 side instead:
granting the OAC principal `s3:ListBucket` makes S3 answer 404 NoSuchKey
rather than 403 AccessDenied. Runbook Part 1 does that first, and its
verification step is what proves this mapping is reached. */
const cer = cfg.CustomErrorResponses ?? { Quantity: 0, Items: [] };
const cerItems = cer.Items ?? [];
const has404 = cerItems.some(
(i) =>
i.ErrorCode === 404 &&
i.ResponsePagePath === ERROR_PAGE &&
String(i.ResponseCode) === '404',
);
if (has404) {
console.log('· 404 -> /404.html (404) already configured');
} else {
/* Report a REPLACEMENT as a replacement. This branch filters out any existing
404 mapping, so on a distribution that maps 404 to a different page the
operator would otherwise be told a mapping was "added" while one was
silently changed — and Part 3 tells them to carry on when the change count is
lower than expected. The function-association branch above already names
what it replaces; this one did not. */
const replaced = cerItems.find((i) => i.ErrorCode === 404);
const items = cerItems.filter((i) => i.ErrorCode !== 404);
items.push({
ErrorCode: 404,
ResponsePagePath: ERROR_PAGE,
ResponseCode: '404',
/* Short, not zero. A 404 is cheap to re-fetch and this is the value that
decides how long a genuinely-missing URL keeps 404ing after the page it
should have been is deployed. */
ErrorCachingMinTTL: 10,
});
cfg.CustomErrorResponses = { Quantity: items.length, Items: items };
changes.push(
replaced
? `CustomErrorResponses 404 -> ${ERROR_PAGE} with status 404 (REPLACING ` +
`${replaced.ResponsePagePath} with status ${replaced.ResponseCode})`
: `CustomErrorResponses += 404 -> ${ERROR_PAGE} with status 404`,
);
}
/* ---- 3. the /api/* origin and behaviour --------------------------------- */
const origins = cfg.Origins.Items ?? [];
if (origins.some((o) => o.Id === ORIGIN_ID)) {
console.log(`· origin ${ORIGIN_ID} already present`);
} else {
origins.push({
Id: ORIGIN_ID,
DomainName: API_DOMAIN,
OriginPath: '',
CustomHeaders: { Quantity: 0 },
CustomOriginConfig: {
HTTPPort: 80,
HTTPSPort: 443,
/* https-only to the origin. The API is public over TLS and there is no
reason for a leg of this in plaintext. */
OriginProtocolPolicy: 'https-only',
OriginSslProtocols: { Quantity: 1, Items: ['TLSv1.2'] },
OriginReadTimeout: 30,
OriginKeepaliveTimeout: 5,
},
ConnectionAttempts: 3,
ConnectionTimeout: 10,
OriginShield: { Enabled: false },
});
cfg.Origins = { Quantity: origins.length, Items: origins };
changes.push(
`Origins += ${ORIGIN_ID} -> ${API_DOMAIN} (https-only, TLSv1.2)`,
);
}
const behaviours = cfg.CacheBehaviors?.Items ?? [];
if (behaviours.some((b) => b.PathPattern === PATH_PATTERN)) {
console.log(`· cache behaviour ${PATH_PATTERN} already present`);
} else {
behaviours.push({
PathPattern: PATH_PATTERN,
TargetOriginId: ORIGIN_ID,
ViewerProtocolPolicy: 'https-only',
/* POST is the one that matters; the rest are here because CloudFront only
offers the three fixed method sets and this is the set containing POST. */
AllowedMethods: {
Quantity: 7,
Items: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'],
CachedMethods: { Quantity: 2, Items: ['GET', 'HEAD'] },
},
CachePolicyId: cachingDisabled,
OriginRequestPolicyId: allViewerExceptHost,
Compress: false,
SmoothStreaming: false,
FieldLevelEncryptionId: '',
/* NO FUNCTION ASSOCIATION, AND THE OMISSION IS LOAD-BEARING. `router.js`
would 301 `/api/intake` to `/api/intake/`, and a 301 turns a POST into a
GET — the submission body would be dropped with a 200 at the end of it.
`infra/cloudfront/router.test.mjs` carries that case as documentation. */
FunctionAssociations: { Quantity: 0 },
LambdaFunctionAssociations: { Quantity: 0 },
TrustedKeyGroups: { Enabled: false, Quantity: 0 },
});
cfg.CacheBehaviors = { Quantity: behaviours.length, Items: behaviours };
changes.push(
`CacheBehaviors += ${PATH_PATTERN} -> ${ORIGIN_ID}, CachingDisabled, AllViewerExceptHostHeader, POST allowed`,
);
}
/* CloudFront matches cache behaviours in order and the FIRST match wins, so a
`/api/*` behaviour placed after a hypothetical `/*` one would never be
reached. There is no `/*` behaviour today — the default behaviour is the
catch-all and is not part of this list — but assert it rather than assume it. */
const catchAll = (cfg.CacheBehaviors?.Items ?? []).findIndex(
(b) => b.PathPattern === '*' || b.PathPattern === '/*',
);
const apiIndex = (cfg.CacheBehaviors?.Items ?? []).findIndex(
(b) => b.PathPattern === PATH_PATTERN,
);
if (catchAll !== -1 && catchAll < apiIndex) {
throw new Error(
`a catch-all behaviour at index ${catchAll} precedes ${PATH_PATTERN} at ${apiIndex}` +
'the API behaviour would never match. Reorder before applying.',
);
}
console.log('');
if (changes.length === 0) {
console.log(
'NOTHING TO CHANGE — the distribution already carries all three.',
);
process.exit(0);
}
console.log(
`${changes.length} change(s) to distribution ${DIST} (ETag ${etag}):`,
);
for (const c of changes) console.log(` + ${c}`);
console.log('');
if (!APPLY) {
console.log('DRY RUN — nothing was sent. Re-run with --apply to write it.');
process.exit(0);
}
const res = aws([
'cloudfront',
'update-distribution',
'--id',
DIST,
'--if-match',
etag,
'--distribution-config',
JSON.stringify(cfg),
'--output',
'json',
]);
console.log(
`APPLIED. Status=${res.Distribution.Status} ETag=${res.ETag}\n` +
'CloudFront takes a few minutes to deploy. Wait for Deployed, then run the ' +
"runbook's verification block:\n" +
` aws cloudfront wait distribution-deployed --id ${DIST}`,
);
+112
View File
@@ -0,0 +1,112 @@
/**
* CloudFront Function, VIEWER REQUEST, on the default cache behaviour only.
*
* ⚠️ THE SITE DOES NOT WORK WITHOUT THIS. `astro.config.mjs` sets
* `trailingSlash: 'always'` and `build.format: 'directory'`, so every route is
* `<dir>/index.html`. CloudFront forwards the viewer path to the S3 REST origin
* unchanged, S3 has no key `about/`, and the request fails. Measured on the live
* distribution 2026-09-01, before this function existed: `/about/` and
* `/definitely-not-a-page/` both returned **403 with an 111-byte
* `application/xml` body** — S3's AccessDenied, served raw to the reader. Only
* `/` worked, via the distribution's default root object. That is 22 of the 23
* pages.
*
* ⚠️ DO NOT ASSOCIATE IT WITH THE `/api/*` BEHAVIOUR. The intake path
* `/api/intake` has no extension and no trailing slash, so the redirect branch
* below would answer a form POST with a 301 — and a 301 turns a POST into a GET,
* which would lose the submission body silently. The association is per
* behaviour and `/api/*` gets none.
*
* Two rules, and the second is a `docs/04` requirement rather than a nicety:
*
* /about/ -> rewrite to /about/index.html (the origin has that key)
* /about -> 301 to /about/ (one canonical URL per page)
*
* Anything with a file extension in its last segment is left alone —
* `robots.txt`, `sitemap-0.xml`, `/_astro/*`, `/fonts/*`, `/og/*.jpg`,
* `favicon.ico`, `pouya-lajevardi-bio.pdf`, and `404.html` itself.
*
* Written to the `cloudfront-js-2.0` runtime and deliberately conservative: no
* arrow functions, no `String.prototype.endsWith`, no template literals. The
* runtime supports more than this; a viewer-request function runs on every
* request to the site and is the wrong place to be clever.
*/
/* The header-injection surface, and nothing else: C0 controls, DEL, space, and
WHATWG's query percent-encode set (`"`, `#`, `<`, `>`). `#` is in because it
changes the STRUCTURE of the Location — left in, `?a=x#&b=y` drops `&b=y` into
a fragment. `| ^ ` { }` are NOT in, and must not be added: browsers send them
raw and `|` is routine in tracking values. Strip rather than encode — these
values arrive percent-encoded, so encoding again makes `%20` into `%2520`. */
function safe(part) {
// eslint-disable-next-line no-control-regex
return String(part).replace(/[\u0000-\u0020\u007f"<>#]/g, '');
}
function handler(event) {
var request = event.request;
/* ⚠️ NORMALISE, THEN REDIRECT IF ANYTHING CHANGED. Leading `//` and `\` are
collapsed because CloudFront forwards duplicate slashes verbatim (it resolves
dot-segments; it does not collapse `//`) and `Location: //host/x` is a
network-path reference that REPLACES THE AUTHORITY — RFC 3986 s4.2. `\` does
the same, because the URL Standard maps it to `/` in special schemes.
Redirect rather than rewrite, or `//about/` serves the About page at a second
URL with a 200. Only the leading run: an interior `//` is a key that does not
exist. */
var uri = request.uri.replace(/\\/g, '/').replace(/^\/+/, '/');
var normalised = uri !== request.uri;
var lastSlash = uri.lastIndexOf('/');
var lastSegment = uri.substring(lastSlash + 1);
// A file, not a route.
if (lastSegment.indexOf('.') !== -1) {
if (normalised) return moved(uri, request);
return request;
}
// A directory-style route: hand the origin the key it actually holds.
if (lastSegment === '') {
if (normalised) return moved(uri, request);
request.uri = uri + 'index.html';
return request;
}
/* Extensionless and no trailing slash. Redirect rather than rewrite, so the
page has ONE address: serving it at both would put two indexable URLs on the
same content, which `docs/04` treats as its primary concern. */
return moved(uri + '/', request);
}
/**
* 301 to a path on this origin, carrying the query string. `location` is always
* built from an already-normalised path, which is what keeps it same-origin.
*/
function moved(path, request) {
var qs = '';
var names = Object.keys(request.querystring);
for (var i = 0; i < names.length; i++) {
var name = names[i];
var value = request.querystring[name];
if (value.multiValue) {
for (var j = 0; j < value.multiValue.length; j++) {
qs +=
(qs === '' ? '' : '&') +
safe(name) +
'=' +
safe(value.multiValue[j].value);
}
} else {
/* Always `name=value`, so `?ref` and `?ref=` normalise to one form rather
than the function guessing which the viewer meant. */
qs += (qs === '' ? '' : '&') + safe(name) + '=' + safe(value.value);
}
}
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: path + (qs === '' ? '' : '?' + qs) },
'cache-control': { value: 'public, max-age=0, must-revalidate' },
},
};
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Unit test for the viewer-request router. `node infra/cloudfront/router.test.mjs`.
*
* The function file cannot use module syntax — CloudFront's runtime has no
* `export` — so it is read and evaluated rather than imported. `aws cloudfront
* test-function` is the authoritative check because it runs the real runtime;
* this one runs in a second, catches the branch mistakes, and costs nothing.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, 'router.js'), 'utf8');
const handler = new Function(`${src}; return handler;`)();
const req = (uri, querystring = {}) => ({ request: { uri, querystring } });
const CASES = [
// [uri, querystring, expected] — expected is {uri} for a rewrite/passthrough
// or {status, location} for a redirect.
['/', {}, { uri: '/index.html' }],
['/about/', {}, { uri: '/about/index.html' }],
['/practice/construction/', {}, { uri: '/practice/construction/index.html' }],
['/contact/received/', {}, { uri: '/contact/received/index.html' }],
['/about', {}, { status: 301, location: '/about/' }],
['/practice/energy', {}, { status: 301, location: '/practice/energy/' }],
// Files are untouched — every one of these is a real object in dist/.
['/robots.txt', {}, { uri: '/robots.txt' }],
['/sitemap-index.xml', {}, { uri: '/sitemap-index.xml' }],
['/404.html', {}, { uri: '/404.html' }],
['/favicon.ico', {}, { uri: '/favicon.ico' }],
['/pouya-lajevardi-bio.pdf', {}, { uri: '/pouya-lajevardi-bio.pdf' }],
['/_astro/schema.Cm5su60K.css', {}, { uri: '/_astro/schema.Cm5su60K.css' }],
['/og/mediation.jpg', {}, { uri: '/og/mediation.jpg' }],
// The query string survives the redirect, normalised to `name=value`.
[
'/fees',
{ utm_source: { value: 'linkedin' }, ref: { value: '' } },
{ status: 301, location: '/fees/?utm_source=linkedin&ref=' },
],
/* ⚠️ THE OPEN-REDIRECT CASES. CloudFront forwards duplicate leading slashes
verbatim (it collapses dot-segments but not `//`), so without normalisation
`//evil.example.com/x` produced `Location: //evil.example.com/x/` — a
network-path reference that sends the viewer to another host from this
domain's own URL. The backslash form defeats a `startsWith('//')` guard,
because the URL Standard maps `\` to `/` in special schemes. Both must stay
same-origin, and both must keep a SINGLE leading slash. */
[
'//evil.example.com/x',
{},
{ status: 301, location: '/evil.example.com/x/' },
],
[
'///evil.example.com/x',
{},
{ status: 301, location: '/evil.example.com/x/' },
],
[
'/\\evil.example.com/x',
{},
{ status: 301, location: '/evil.example.com/x/' },
],
/* ⚠️ A NORMALISED PATH IS REDIRECTED, NOT REWRITTEN — this asserted a 200 for
one revision, which closed the redirect and opened an unbounded family of
duplicate URLs for every page on the site. */
[
'//evil.example.com/x/',
{},
{ status: 301, location: '/evil.example.com/x/' },
],
['//about/', {}, { status: 301, location: '/about/' }],
['///about/', {}, { status: 301, location: '/about/' }],
['/\\about/', {}, { status: 301, location: '/about/' }],
/* A file is normalised too. This branch returned `request` untouched for one
revision, so `//robots.txt` reached S3 with the doubled slash and 404'd. */
['//robots.txt', {}, { status: 301, location: '/robots.txt' }],
['/\\robots.txt', {}, { status: 301, location: '/robots.txt' }],
/* An interior `//` is left alone on purpose: it is a key that does not exist,
so it resolves to the 404 page. Only the leading run is a security question. */
['/a//b/', {}, { uri: '/a//b/index.html' }],
/* Header-injection surface: CR, LF, space and the delimiters browsers disagree
about are stripped rather than re-encoded — an already-encoded value must not
be encoded twice. `%20` therefore passes through untouched. */
[
'/fees',
{ q: { value: 'a b"><x' }, utm: { value: 'a%20b' } },
{ status: 301, location: '/fees/?q=abx&utm=a%20b' },
],
[
'/fees',
{ evil: { value: 'x\r\nSet-Cookie: a=b' } },
{ status: 301, location: '/fees/?evil=xSet-Cookie:a=b' },
],
/* `#` changes the STRUCTURE of the Location — without stripping it, `&b=y`
lands in a fragment and the parameter is silently lost. */
[
'/fees',
{ a: { value: 'x#&b=y' } },
{ status: 301, location: '/fees/?a=x&b=y' },
],
/* ⚠️ AND THESE MUST SURVIVE. `| ^ ` { }` are not in WHATWG's query
percent-encode set, so a browser sends them raw — and `|` is routine in
ad-platform tracking values. One revision of `safe()` stripped all of them,
silently corrupting exactly the campaign links the 301 exists to preserve. */
[
'/fees',
{ utm_content: { value: 'banner|top' }, k: { value: 'a{b}c^d`e' } },
{ status: 301, location: '/fees/?utm_content=banner|top&k=a{b}c^d`e' },
],
// multiValue, which no case exercised before.
[
'/fees',
{ tag: { value: 'a', multiValue: [{ value: 'a' }, { value: 'b' }] } },
{ status: 301, location: '/fees/?tag=a&tag=b' },
],
/* /api/intake must NEVER be redirected — a 301 turns a POST into a GET and
the submission body is gone. This function is not associated with the
/api/* behaviour, so this case documents WHY the association matters: if it
ever were associated, this is the damage. */
['/api/intake', {}, { status: 301, location: '/api/intake/' }],
];
let pass = 0;
const failures = [];
for (const [uri, qs, expected] of CASES) {
const out = handler(req(uri, qs));
let actual;
if (out.statusCode) {
actual = { status: out.statusCode, location: out.headers.location.value };
} else {
actual = { uri: out.uri };
}
if (JSON.stringify(actual) === JSON.stringify(expected)) pass += 1;
else
failures.push(
`${uri} -> ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`,
);
}
if (pass + failures.length !== CASES.length) {
throw new Error(`case count ${pass + failures.length} != ${CASES.length}`);
}
console.log(`router: ${pass} of ${CASES.length} cases pass`);
for (const f of failures) console.error(' FAIL ' + f);
if (failures.length > 0) process.exit(1);