feat(site): move the marketing site from Vercel to Cloudflare Pages

www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.

The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.

Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.

Four behaviour differences were found by measurement and are handled here:

- The blog post's COOP/COEP was already broken in production. vercel.json
  scoped the headers to the bare URL, but the page's own canonical is the
  trailing-slash form, which served 200 with no isolation headers — so
  search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
  degraded. public/_headers covers both forms.

- Pages answers unknown URLs with the homepage at HTTP 200 when the
  output has no 404.html — a soft-404 that invites indexing junk URLs as
  the homepage. Hence src/pages/404.astro.

- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
  submissions are forbidden"); Pages does not, and a cross-site <form>
  submit needs no CORS permission to be sent, so the allowlist cannot
  stop it. The Function reproduces the guard; JSON posts stay exempt as
  that is demo.pcbjam.com's allowlisted path.

- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
  generated route config, so it is now an explicit _headers rule.

Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.

privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
This commit is contained in:
Viktor Vaczi 2026-07-27 13:34:12 +02:00
commit 7edfade53c
41 changed files with 2443 additions and 910 deletions

77
deploy/site/00-baseline.sh Executable file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Freeze the LIVE Vercel behaviour as the reference every later step is compared
# against. Read-only; needs no Cloudflare credentials. Run this while Vercel is
# still serving — you cannot re-create it afterwards.
#
# deploy/site/00-baseline.sh [--force]
#
# Two probes are EXPECTED to fail here: post_coi (the blog post's COOP/COEP) is
# genuinely broken in production today — the page's canonical is the
# trailing-slash URL and that URL serves 200 with no isolation headers. Recording
# it is the point: 08-verify-prod.sh asserts those same probes PASS afterwards,
# so the migration proves it fixed the bug rather than porting it.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/parity.sh"
require_cmd curl dig awk sed jq
OUT="$STATE_DIR/baseline/vercel"
if [ -d "$OUT" ] && [ "${1:-}" != "--force" ]; then
die "baseline already exists at $OUT — refusing to overwrite (use --force).
Re-baselining after cutover would silently replace the reference."
fi
mkdir -p "$OUT"
section "confirming $PROD_BASE is still served by Vercel"
hdrs="$(_headers "$PROD_BASE/")"
if [ -z "$(_hdr "$hdrs" x-vercel-id)" ]; then
die "no x-vercel-id header on $PROD_BASE — this host is not on Vercel any more.
Baselining a Cloudflare response as 'the Vercel reference' would be useless."
fi
echo "ok: x-vercel-id present"
section "DNS + SOA snapshot"
{
echo "# captured $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "apex_cname=$(dig +short CNAME "$ZONE_NAME" || true)"
echo "www_cname=$(dig +short CNAME "www.$ZONE_NAME" || true)"
echo "ns=$(dig +short NS "$ZONE_NAME" | sort | tr '\n' ' ')"
# The SOA minimum is the NEGATIVE cache TTL. It is the number that makes
# delete-then-create dangerous: a resolver that asks while the record is gone
# caches NODATA for this long, and you cannot flush it.
echo "soa=$(dig +short SOA "$ZONE_NAME" || true)"
echo "soa_minimum_ttl=$(dig +short SOA "$ZONE_NAME" | awk '{print $NF}')"
} | tee "$OUT/dns.txt"
section "page titles (used to detect a soft-404 later)"
printf 'home_title=%s\n' "$(_title "$PROD_BASE/")" | tee "$OUT/titles.txt"
section "header snapshots"
for p in / /pricing /blog /privacy /blog/porting-kicad-graphics-to-webgl-in-2026 /gerber-demo/boot.js; do
f="$(printf '%s' "$p" | sed 's|/|_|g')"; [ "$f" = "_" ] && f="_home"
t="$(_trace "$PROD_BASE$p")"; eff="$(printf '%s' "$t" | cut -f1)"
{
echo "# requested: $PROD_BASE$p"
echo "# effective: $eff (hops $(printf '%s' "$t" | cut -f3))"
# Drop volatile headers so a later diff shows real changes, not timestamps.
_headers "$eff" | grep -vE '^(date|age|etag|last-modified|content-length|server|cf-ray|cf-cache-status|nel|report-to|alt-svc|set-cookie|x-vercel-id|x-vercel-cache|x-matched-path|expires|via):' | sort
} > "$OUT/$f.headers"
echo " $p -> $OUT/$f.headers"
done
rc=0
assert_parity "$PROD_BASE" --scope prod || rc=$?
assert_apex_redirect "$APEX_BASE" || rc=$?
{
echo "# baseline captured $(date -u +%Y-%m-%dT%H:%M:%SZ) against $PROD_BASE (Vercel)"
echo "# parity exit code: $rc (non-zero is EXPECTED — see the header of this script)"
} > "$OUT/parity-exit.txt"
section "done"
echo "Baseline written to $OUT"
echo
echo "Read the FAIL rows above and keep them: they are the 'before' half of the"
echo "COOP/COEP fix. 08-verify-prod.sh requires those same probes to pass."
echo "done: file://$OUT"

118
deploy/site/01-preflight.sh Executable file
View file

@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Prove every credential and token scope the later steps need, and snapshot DNS
# for rollback. Read-only — makes no changes anywhere.
#
# export CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=...
# deploy/site/01-preflight.sh
#
# Re-run this freely; it is the script to come back to after fixing a scope or
# logging wrangler in.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl dig jq awk node npm npx shasum
ok=0; bad=0
chk() { # chk "<label>" <0|1> ["remediation"]
if [ "$2" = 0 ]; then printf ' ok %s\n' "$1"; ok=$((ok+1))
else printf ' FAIL %s\n' "$1"; [ -n "${3:-}" ] && printf ' -> %s\n' "$3"; bad=$((bad+1)); fi
}
section "toolchain"
nv="$(node -v | sed 's/^v//')"
node_ok=1
# Astro 6 needs >= 22.12.0
maj="${nv%%.*}"; rest="${nv#*.}"; min="${rest%%.*}"
if [ "$maj" -gt 22 ] 2>/dev/null || { [ "$maj" = 22 ] && [ "$min" -ge 12 ]; } 2>/dev/null; then node_ok=0; fi
chk "node $nv >= 22.12 (Astro 6)" $node_ok "install Node 22.12+ (nvm use 22)"
chk "wrangler reachable ($($WRANGLER --version 2>/dev/null | head -1))" \
"$($WRANGLER --version >/dev/null 2>&1; echo $?)"
section "cloudflare auth"
if [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then
chk "CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID exported" 0
tv="$(cf_api GET /user/tokens/verify 2>/dev/null || true)"
st="$(printf '%s' "$tv" | jq -r '.result.status // "unknown"' 2>/dev/null || echo unknown)"
chk "token status = active (got '$st')" "$([ "$st" = active ] && echo 0 || echo 1)"
else
chk "CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID exported" 1 \
"the API steps (03,07,09) need both. Create a token with the scopes listed in lib/cf-api.sh"
wo="$($WRANGLER whoami 2>&1 || true)"
case "$wo" in
*"Not logged in"*|*"could not be refreshed"*)
chk "wrangler logged in" 1 "run: $WRANGLER login (the local token is expired)" ;;
*) chk "wrangler logged in" 0 ;;
esac
fi
if [ -n "${CLOUDFLARE_API_TOKEN:-}" ]; then
section "zone"
zid="$(cf_zone_id 2>/dev/null || true)"
chk "zone $ZONE_NAME resolved (${zid:-none})" "$([ -n "$zid" ] && echo 0 || echo 1)"
if [ -n "$zid" ]; then
section "token scopes (each probe maps to one permission)"
cf_api GET "/zones/$zid/dns_records?per_page=5" >/dev/null 2>&1
chk "Zone -> DNS -> Read/Edit" $?
# 404 here is fine and expected — it means the redirect phase has no ruleset
# yet. 403 is the failure we are probing for.
rs="$($CURL_BIN -sS "$CF_API/zones/$zid/rulesets/phases/http_request_dynamic_redirect/entrypoint" \
-H "Authorization: Bearer $(cf_token)" || true)"
code="$(printf '%s' "$rs" | jq -r '.errors[0].code // "ok"' 2>/dev/null)"
chk "Zone -> Dynamic Redirect -> Edit (probe code=$code)" \
"$([ "$code" = "ok" ] || [ "$code" = "10000" ] || printf '%s' "$rs" | jq -e '.success==false and (.errors[0].code==1002 or .errors[0].code==10000)' >/dev/null 2>&1; echo $?)" \
"add 'Dynamic Redirect: Edit' (sometimes shown as Config/Transform Rules) to the token"
cf_api GET "/zones/$zid/settings/security_header" >/dev/null 2>&1
chk "Zone -> Zone Settings -> Edit (HSTS)" $?
cf_api GET "/accounts/$(cf_account)/pages/projects" >/dev/null 2>&1
chk "Account -> Cloudflare Pages -> Edit" $?
section "free-plan redirect headroom"
nrules="$(printf '%s' "$rs" | jq '.result.rules | length' 2>/dev/null || echo 0)"
[ "$nrules" = "null" ] && nrules=0
chk "existing dynamic redirects: $nrules (< 10 on the free plan)" \
"$([ "$nrules" -lt 10 ] && echo 0 || echo 1)"
section "DNS snapshot -> state/dns-before.json (rollback source of truth)"
cf_dns_list > "$STATE_DIR/state/dns-before.json"
for spec in "$ZONE_NAME:CNAME" "www.$ZONE_NAME:CNAME"; do
n="${spec%%:*}"; t="${spec##*:}"
c="$(jq -r --arg n "$n" --arg t "$t" '.result[]|select(.name==$n and .type==$t)|.content' \
"$STATE_DIR/state/dns-before.json" | head -1)"
chk "$n $t -> ${c:-MISSING} (expect $VERCEL_DNS_TARGET)" \
"$([ "$c" = "$VERCEL_DNS_TARGET" ] && echo 0 || echo 1)" \
"the record moved since this migration was planned — re-read the plan before continuing"
done
mx="$(jq -r '.result[]|select(.type=="MX")|.content' "$STATE_DIR/state/dns-before.json" | head -1)"
chk "Google MX still present ($mx) — must survive untouched" \
"$([ -n "$mx" ] && echo 0 || echo 1)"
fi
fi
section "nameservers"
ns="$(dig +short NS "$ZONE_NAME" | tr '\n' ' ')"
case "$ns" in *becky*|*ernest*) chk "on Cloudflare NS ($ns)" 0 ;;
*) chk "on Cloudflare NS ($ns)" 1 "the zone must be on Cloudflare nameservers" ;;
esac
section "vercel (needed only by 09-detach / 99-rollback)"
if command -v vercel >/dev/null 2>&1 || [ -n "${VERCEL_TOKEN:-}" ]; then
names="$(npx --yes vercel@latest env ls production --project "$VERCEL_PROJECT" --scope "$VERCEL_TEAM" 2>/dev/null \
| awk '/^ [A-Z]/{print $1}' | sort | tr '\n' ' ' || true)"
echo " env var NAMES on Vercel (values never read): ${names:-<unavailable>}"
[ -n "$names" ] && printf '%s\n' "$names" > "$STATE_DIR/state/vercel-env-names.txt"
for want in RESEND_API_KEY RESEND_SEGMENT_ID WAITLIST_FROM_EMAIL; do
case " $names " in *" $want "*) chk "$want present on Vercel" 0 ;;
*) chk "$want present on Vercel" 1 "expected it there; 04-set-secrets.sh needs its value" ;;
esac
done
else
warn "vercel CLI not on PATH and VERCEL_TOKEN unset — skipping (only 09/99 need it)"
fi
section "summary"
echo " $ok ok, $bad failed"
[ "$bad" -eq 0 ] || die "preflight failed — fix the FAIL rows above before continuing."
stamp_write 01-preflight "zone=${zid:-unknown}"
echo "done: preflight ok (zone=${zid:-unknown})"

121
deploy/site/02-verify-local.sh Executable file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Build the site and run it the way Cloudflare Pages will, then sweep it. This is
# the gate: 05-deploy.sh refuses to deploy a tree that has not passed here.
#
# deploy/site/02-verify-local.sh [--port 8788] [--skip-build] [--keep-running]
#
# Writes a TEMPORARY .dev.vars containing a deliberately BOGUS RESEND_API_KEY.
# That is on purpose: a valid-email POST must then come back 502 send_failed,
# which proves the `resend` SDK actually resolved and issued a request under the
# Pages runtime. A module-resolution error instead means nodejs_compat isn't
# taking effect (it is declared in site/wrangler.toml).
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/parity.sh"
require_cmd curl jq awk sed node npm npx
PORT=8788; SKIP_BUILD=0; KEEP=0
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
--keep-running) KEEP=1; shift ;;
*) die "unknown arg: $1" ;;
esac
done
cd "$SITE_DIR"
section "structural checks (before spending time on a build)"
[ -f functions/api/waitlist.ts ] || die "functions/api/waitlist.ts is missing — the waitlist endpoint would 404"
grep -q 'adapter:' astro.config.mjs && die "astro.config.mjs still configures an adapter; this deploy is pure static"
grep -q 'astro:env/server' functions/api/waitlist.ts && \
die "functions/api/waitlist.ts imports astro:env/server, which does not exist in a Pages Function"
for n in RESEND_API_KEY RESEND_SEGMENT_ID WAITLIST_FROM_EMAIL WAITLIST_ALLOWED_ORIGINS; do
grep -q "$n" functions/api/waitlist.ts || die "functions/api/waitlist.ts never reads $n"
done
echo "ok: no adapter, Function present, reads all four env names"
if [ "$SKIP_BUILD" = 0 ]; then
section "npm ci + test + build"
npm ci
npm test
npm run build
fi
section "build output"
for f in dist/404.html dist/_headers dist/_routes.json dist/index.html; do
[ -e "$f" ] || die "$f missing after build"
echo " ok $f"
done
[ -d dist/server ] && die "dist/server exists — an adapter crept back in"
[ -d .vercel ] && die ".vercel/ was produced — the Vercel adapter is still wired up"
# A /* rule would isolate the landing page and break the YouTube hero embed.
if grep -qE '^/\*[[:space:]]*$' dist/_headers; then
die "_headers contains a bare /* rule — that would apply COOP/COEP site-wide and break the YouTube embed on /"
fi
grep -q 'porting-kicad-graphics-to-webgl-in-2026/\*' dist/_headers \
|| die "_headers has no trailing-slash rule for the blog post; COOP/COEP would land on the redirect, not the document"
echo "ok: _headers scoped correctly, 404.html present"
section "starting wrangler pages dev on :$PORT"
if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
die "port $PORT is already in use — stop the other process or pass --port"
fi
DEV_VARS_CREATED=0
if [ -f .dev.vars ]; then
warn ".dev.vars already exists; leaving it alone (the 502 probe may not apply)"
else
printf 'RESEND_API_KEY=re_bogus_key_for_local_verification_only\nRESEND_SEGMENT_ID=seg_local\nWAITLIST_FROM_EMAIL=PCBJam <hello@pcbjam.com>\n' > .dev.vars
DEV_VARS_CREATED=1
fi
LOG="$STATE_DIR/logs/pages-dev-$(date +%s).log"
$WRANGLER pages dev --port "$PORT" --ip 127.0.0.1 > "$LOG" 2>&1 &
DEV_PID=$!
cleanup() {
[ "$KEEP" = 1 ] && { echo; echo "left running: http://127.0.0.1:$PORT (pid $DEV_PID)"; return; }
kill "$DEV_PID" 2>/dev/null || true
wait "$DEV_PID" 2>/dev/null || true
[ "$DEV_VARS_CREATED" = 1 ] && rm -f "$SITE_DIR/.dev.vars"
return 0
}
trap cleanup EXIT INT TERM
for _ in $(seq 1 40); do
curl -sf "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break
sleep 0.5
done
curl -sf "http://127.0.0.1:$PORT/" >/dev/null 2>&1 \
|| die "pages dev never became ready — see $LOG"
grep -q 'valid header rules' "$LOG" && echo "ok: $(grep -o '[0-9]* valid header rules' "$LOG" | head -1) parsed"
rc=0
assert_parity "http://127.0.0.1:$PORT" --scope local --live-post || rc=$?
section "resend SDK loads under the Pages runtime"
# With the bogus key in .dev.vars this MUST be 502 send_failed. 200 would mean the
# key never reached the Function; a 500 would mean the module failed to resolve.
code="$(curl -sS -o "$STATE_DIR/state/live-post.json" -w '%{http_code}' \
-X POST "http://127.0.0.1:$PORT/api/waitlist" \
-H 'content-type: application/json' \
--data '{"email":"sdk-probe@example.com","source":"cfm-verify"}' 2>/dev/null || true)"
body="$(cat "$STATE_DIR/state/live-post.json" 2>/dev/null || true)"
if [ "$DEV_VARS_CREATED" = 1 ]; then
case "$code:$body" in
502:*send_failed*) echo "ok: 502 send_failed — SDK resolved and called Resend" ;;
200:*) echo "FAIL: 200 — the bogus RESEND_API_KEY never reached the Function"; rc=1 ;;
*) echo "FAIL: status=$code body=$body (expected 502 send_failed; a module error means nodejs_compat isn't applying)"; rc=1 ;;
esac
else
echo "skipped (pre-existing .dev.vars): status=$code"
fi
[ "$rc" -eq 0 ] || die "local verification FAILED — do not deploy. Logs: $LOG"
stamp_write 02-local-parity "port=$PORT"
section "done"
echo "done: local parity ok (ctx=$(ctx_hash)) — 05-deploy.sh will accept this tree"

View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Idempotently ensure the Cloudflare Pages project exists with the right
# production branch and compatibility settings.
#
# deploy/site/03-ensure-project.sh # dry run
# deploy/site/03-ensure-project.sh --apply
#
# Naming this "ensure" rather than "create" is the contract: running it twice is
# normal and must not look like an error.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl jq npx
parse_common_flags "$@"
dry_banner
section "project $PAGES_PROJECT"
if existing="$(cf_pages_project 2>/dev/null)"; then
echo "exists already"
else
existing=""
dry "create Pages project $PAGES_PROJECT (production branch: $PAGES_PROD_BRANCH)" -- \
$WRANGLER pages project create "$PAGES_PROJECT" \
--production-branch "$PAGES_PROD_BRANCH" \
--compatibility-date 2026-06-01 --compatibility-flags nodejs_compat
[ "$DRY_RUN" = 1 ] && { echo; echo "done: (dry run) would create $PAGES_PROJECT"; exit 0; }
existing="$(cf_pages_project)"
fi
section "asserting settings"
pb="$(printf '%s' "$existing" | jq -r '.result.production_branch // "?"')"
src="$(printf '%s' "$existing" | jq -r '.result.source // "null"')"
sub="$(printf '%s' "$existing" | jq -r '.result.subdomain // "?"')"
# The single most expensive mistake available here: if the project's production
# branch is anything other than what deploy-site.yml passes to --branch, every
# deploy lands as a PREVIEW and the live site silently never updates. The same
# warning is written into deploy-demo.yml.
if [ "$pb" != "$PAGES_PROD_BRANCH" ]; then
die "production_branch is '$pb' but deploys use '$PAGES_PROD_BRANCH'.
Every deploy would land as a preview and www.pcbjam.com would never update.
Fix it in Pages -> $PAGES_PROJECT -> Settings -> Builds & deployments, then re-run.
(This script will NOT change it: doing so retroactively re-labels deployments.)"
fi
echo " ok production_branch = $pb"
if [ "$src" != "null" ]; then
die "project is Git-connected (source: $src). Cloudflare's own builds would race
the uploads from deploy-site.yml. Disconnect it in the dashboard first."
fi
echo " ok Direct Upload (not Git-connected)"
section "done"
echo "note: the custom domain is NOT set here — there is no 'wrangler pages domain'"
echo " subcommand. 07-dns-cutover.sh attaches www.pcbjam.com via the API."
echo "done: https://${sub}.pages.dev"

104
deploy/site/04-set-secrets.sh Executable file
View file

@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Put the three waitlist secrets into the Pages project, for BOTH the production
# and preview environments, without ever printing a value.
#
# deploy/site/04-set-secrets.sh --prompt --apply
# deploy/site/04-set-secrets.sh --from-env-file site/.migration-secrets.env --apply
#
# Getting the values: pull them yourself, e.g.
# cd site && npx vercel env pull .migration-secrets.env \
# --environment=production --project pcbjam --scope pcbj-am
# .migration-secrets.env is gitignored. Delete it when you're done.
#
# Pages applies environment-variable changes to NEW deployments only, so this must
# run BEFORE 05-deploy.sh (or you must redeploy afterwards).
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
no_xtrace # never run with tracing: values would be echoed
require_cmd npx shasum awk
NAMES="RESEND_API_KEY RESEND_SEGMENT_ID WAITLIST_FROM_EMAIL"
MODE=prompt; ENV_FILE=""
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--prompt) MODE=prompt; shift ;;
--from-env-file) MODE=file; ENV_FILE="$2"; shift 2 ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
dry_banner
# WAITLIST_ALLOWED_ORIGINS is deliberately NOT settable here. It must stay unset
# so the Function keeps its in-code default (https://demo.pcbjam.com), matching
# what Vercel had. Setting it as a Pages secret would work but would hide the
# allowlist from code review.
guard_name() {
case "$1" in
WAITLIST_ALLOWED_ORIGINS)
die "refusing to set WAITLIST_ALLOWED_ORIGINS: it is intentionally unset so
the allowlist lives in functions/api/waitlist.ts where it is reviewable." ;;
esac
grep -q "$1" "$SITE_DIR/functions/api/waitlist.ts" \
|| die "refusing to set $1: functions/api/waitlist.ts never reads it"
}
read_value() { # read_value NAME -> echoes the value (never logged)
_n="$1"
if [ "$MODE" = file ]; then
[ -f "$ENV_FILE" ] || die "env file not found: $ENV_FILE"
( cd "$(dirname "$ENV_FILE")" && git check-ignore -q "$(basename "$ENV_FILE")" ) \
|| die "$ENV_FILE is NOT gitignored — refusing to read secrets from a trackable file"
awk -F= -v k="$_n" '$1==k { sub(/^[^=]*=/,""); gsub(/^"|"$/,""); print; exit }' "$ENV_FILE"
else
printf 'value for %s (input hidden, empty to skip): ' "$_n" >&2
read -r -s _v; echo >&2
printf '%s' "$_v"
fi
}
section "setting secrets on $PAGES_PROJECT"
set_count=0
for n in $NAMES; do
guard_name "$n"
v="$(read_value "$n")"
if [ -z "$v" ]; then warn "$n: empty, skipped"; continue; fi
# Length + short hash only. Enough to compare against Vercel without ever
# revealing the value.
fp="$(printf '%s' "$v" | shasum -a 256 | cut -c1-8)"
echo " $n: len=$(printf '%s' "$v" | wc -c | tr -d ' ') sha256=$fp"
for envname in production preview; do
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: pages secret put $n --project-name $PAGES_PROJECT --env $envname (value via stdin)"
else
# stdin, never argv — an argv value would be visible in `ps`.
printf '%s' "$v" | $WRANGLER pages secret put "$n" \
--project-name "$PAGES_PROJECT" --env "$envname" >/dev/null \
|| die "failed to set $n ($envname)"
echo " set: $n ($envname)"
fi
done
set_count=$((set_count+1))
unset v
done
if [ "$DRY_RUN" = 0 ]; then
section "verifying (names only)"
for envname in production preview; do
have="$($WRANGLER pages secret list --project-name "$PAGES_PROJECT" --env "$envname" 2>/dev/null || true)"
for n in $NAMES; do
case "$have" in *"$n"*) echo " ok $n ($envname)" ;;
*) die "$n missing from $envname after setting it" ;;
esac
done
done
stamp_write 04-secrets "count=$set_count"
fi
section "done"
echo "Pages applies env changes to NEW deployments only — run 05-deploy.sh next."
echo "If you used --from-env-file, delete it now: rm -f $ENV_FILE"
echo "done: $set_count secrets set (production + preview)"

71
deploy/site/05-deploy.sh Executable file
View file

@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Build and upload to Cloudflare Pages. Touches no DNS — after this the site is
# live only on *.pages.dev, which is what makes the whole migration safe to
# rehearse.
#
# deploy/site/05-deploy.sh --preview # dry run
# deploy/site/05-deploy.sh --preview --apply
# deploy/site/05-deploy.sh --production --apply
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
require_cmd npx jq
TARGET=""; SKIP_BUILD=0
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--preview) TARGET=preview; shift ;;
--production) TARGET=production; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
[ -n "$TARGET" ] || die "pass --preview or --production"
dry_banner
# The gate. The stamp is keyed on a hash of src/, public/, functions/ and the
# configs, so it cannot vouch for a tree that has been edited since.
stamp_require 02-local-parity
BRANCH="$PAGES_PROD_BRANCH"
[ "$TARGET" = preview ] && BRANCH="cf-migrate-preview"
cd "$SITE_DIR"
if [ "$SKIP_BUILD" = 0 ]; then
section "build"
npm run build
fi
[ -e dist/404.html ] || die "dist/404.html missing — deploying would create a soft-404 (homepage at 200 on every unknown URL)"
[ -e dist/_headers ] || die "dist/_headers missing — the Gerber viewer would lose cross-origin isolation"
[ -f functions/api/waitlist.ts ] || die "functions/api/waitlist.ts missing"
section "deploy ($TARGET, branch=$BRANCH)"
# Run from $SITE_DIR so wrangler reads site/wrangler.toml (pages_build_output_dir
# + nodejs_compat) AND discovers site/functions/. Deploying from the repo root
# would upload the static files and silently omit the Function.
LOG="$STATE_DIR/logs/deploy-$(date +%s).log"
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: (cd $SITE_DIR && $WRANGLER pages deploy --project-name $PAGES_PROJECT --branch $BRANCH --commit-dirty=true)"
echo; echo "done: (dry run) nothing deployed"
exit 0
fi
$WRANGLER pages deploy \
--project-name "$PAGES_PROJECT" \
--branch "$BRANCH" \
--commit-dirty=true 2>&1 | tee "$LOG"
URL="$(grep -Eo 'https://[a-z0-9-]+\.'"$PAGES_PROJECT"'\.pages\.dev' "$LOG" | tail -1 || true)"
[ -n "$URL" ] || URL="$(grep -Eo 'https://[^ ]*\.pages\.dev' "$LOG" | tail -1 || true)"
[ -n "$URL" ] || die "could not determine the deployment URL — see $LOG"
printf '{"url":"%s","branch":"%s","target":"%s","ctx":"%s"}\n' \
"$URL" "$BRANCH" "$TARGET" "$(ctx_hash)" > "$STATE_DIR/state/last-deploy.json"
section "done"
echo "Verify it before any DNS moves:"
echo " deploy/site/06-verify-deploy.sh --url $URL$([ "$TARGET" = production ] && echo ' --scope prod-deploy')"
echo "done: $URL"

95
deploy/site/06-verify-deploy.sh Executable file
View file

@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Sweep a Pages deployment on its *.pages.dev URL — the real Cloudflare runtime,
# before any DNS is touched. One script for both the preview and the production
# deployment; only the stamp it writes differs.
#
# deploy/site/06-verify-deploy.sh --latest
# deploy/site/06-verify-deploy.sh --url https://abc123.pcbjam-site.pages.dev --scope prod-deploy
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/parity.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl jq awk sed
URL=""; SCOPE=preview
while [ $# -gt 0 ]; do
case "$1" in
--url) URL="$2"; shift 2 ;;
--latest) URL=""; shift ;;
--scope) SCOPE="$2"; shift 2 ;;
*) die "unknown arg: $1" ;;
esac
done
if [ -z "$URL" ] && [ -s "$STATE_DIR/state/last-deploy.json" ]; then
URL="$(jq -r .url "$STATE_DIR/state/last-deploy.json")"
fi
[ -n "$URL" ] || die "no deployment URL — pass --url, or run 05-deploy.sh first"
section "target"
echo " $URL (scope=$SCOPE)"
rc=0
assert_parity "$URL" --scope preview || rc=$?
section "Pages-specific checks"
# If the Function were not bundled (e.g. deployed from the wrong directory) the
# whole endpoint would simply be a static 404. The parity sweep would catch it,
# but say so explicitly — it is the most likely deploy-time mistake.
st="$(curl -sS -o /dev/null -w '%{http_code}' -X OPTIONS "$URL/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null || true)"
if [ "$st" = "404" ]; then
echo "FAIL /api/waitlist is 404 — the Function was not bundled."
echo " wrangler discovers functions/ relative to \$PWD; deploy from site/."
rc=1
else
echo "PASS /api/waitlist is routed (status $st)"
fi
# _routes.json restricts the Function to /api/*. Confirm that did not also break
# static 404 handling for everything else.
nf="$(curl -sS -L -o /dev/null -w '%{http_code}' "$URL/__cfm-parity-404__/" 2>/dev/null || true)"
[ "$nf" = "404" ] && echo "PASS static 404 handling intact under _routes.json" \
|| { echo "FAIL unknown path returned $nf, expected 404"; rc=1; }
if [ "$SCOPE" = prod-deploy ]; then
section "confirming this is the PRODUCTION deployment"
latest="$(cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/deployments?per_page=1" 2>/dev/null || true)"
envname="$(printf '%s' "$latest" | jq -r '.result[0].environment // "?"')"
did="$(printf '%s' "$latest" | jq -r '.result[0].id // "?"')"
if [ "$envname" != "production" ]; then
echo "FAIL latest deployment environment is '$envname', not 'production'"
echo " (a branch name other than $PAGES_PROD_BRANCH makes it a preview)"
rc=1
else
echo "PASS latest deployment is production ($did)"
fi
fi
section "diff vs the Vercel baseline (informational)"
BASE="$STATE_DIR/baseline/vercel"
if [ -d "$BASE" ]; then
OUT="$STATE_DIR/baseline/pages-$(date +%s)"; mkdir -p "$OUT"
for p in / /pricing /blog /privacy /blog/porting-kicad-graphics-to-webgl-in-2026 /gerber-demo/boot.js; do
f="$(printf '%s' "$p" | sed 's|/|_|g')"; [ "$f" = "_" ] && f="_home"
eff="$(_trace "$URL$p" | cut -f1)"
_headers "$eff" | grep -vE '^(date|age|etag|last-modified|content-length|server|cf-ray|cf-cache-status|nel|report-to|alt-svc|set-cookie|x-vercel-id|x-vercel-cache|x-matched-path|expires|via):' | sort > "$OUT/$f.headers"
if [ -f "$BASE/$f.headers" ]; then
d="$(diff -u "$BASE/$f.headers" "$OUT/$f.headers" || true)"
[ -n "$d" ] && { echo "--- $p"; printf '%s\n' "$d" | sed -n '3,$p'; }
fi
done
echo "(header differences above are for human review, not assertions)"
else
warn "no baseline at $BASE — run 00-baseline.sh while Vercel is still live"
fi
[ "$rc" -eq 0 ] || die "deployment verification FAILED — do not cut DNS."
case "$SCOPE" in
prod-deploy) stamp_write 06-prod-deploy-parity "url=$URL" "deployment_id=${did:-unknown}" ;;
*) stamp_write 06-preview-parity "url=$URL" ;;
esac
section "done"
echo "done: verified $URL"

347
deploy/site/07-dns-cutover.sh Executable file
View file

@ -0,0 +1,347 @@
#!/usr/bin/env bash
# Move www.pcbjam.com from Vercel to the Pages project, and point the apex at a
# 308 redirect to www. THE ONLY SCRIPT HERE THAT AFFECTS LIVE TRAFFIC.
#
# deploy/site/07-dns-cutover.sh --phase probe # dry run (default)
# deploy/site/07-dns-cutover.sh --phase probe --apply
# ...
# deploy/site/07-dns-cutover.sh --phase swap --apply
# deploy/site/07-dns-cutover.sh --rollback --apply
#
# Phases, in the order they should be run. Only `swap` and `apex` are live:
#
# prelower T-24h drop the www TTL to 60 so the cut AND any rollback
# propagate in ~1 min instead of ~5.
# probe T-days create a throwaway hostname, attach it as a Pages custom
# domain, delete both. Settles — at zero risk to www —
# whether Pages will attach over an existing CNAME, and
# measures how long validation takes.
# rules T-1d create the apex->www redirect rule. INERT until the apex is
# proxied, so it is safe to create early and verify later.
# hsts T-1d enable zone HSTS at Vercel's existing max-age. Additive.
# swap T+0 www -> Pages. See "the window" below.
# apex T+2m apex -> proxied placeholder, which activates the rule.
#
# THE WINDOW. The swap is an in-place PATCH, not a delete-then-create, and that
# distinction is the single most important safety property here. A PATCH changes
# content and proxied status atomically, so there is NO DNS gap. Delete-then-
# create leaves the name with no record for a second or two, and any resolver
# that asks in that instant caches NODATA for the zone's SOA minimum (typically
# 1800s) — an un-flushable ~30-minute partial outage. 00-baseline.sh records the
# actual SOA minimum so you can see the exposure. The residual risk with PATCH is
# HTTP-only and self-healing: for a second or two the edge has no route for the
# host and serves the Pages not-found page. Universal SSL already covers
# *.pcbjam.com, so TLS is never in question.
#
# Vercel stays attached throughout, so resolvers still holding the old answer keep
# serving the identical site. This is a fade, not a switch.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/cf-api.sh"
. "$(dirname "$0")/lib/parity.sh"
require_cmd curl dig jq awk
PHASE=""; ROLLBACK=0; SWAP_MODE=auto
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--phase) PHASE="$2"; shift 2 ;;
--swap-mode) SWAP_MODE="$2"; shift 2 ;;
--rollback) ROLLBACK=1; shift ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
dry_banner
ZID="$(cf_zone_id)"
PROBE_HOST="cf-attach-probe.$ZONE_NAME"
SNAP="$STATE_DIR/state/dns-before.json"
[ -s "$SNAP" ] || die "no DNS snapshot at $SNAP — run 01-preflight.sh first (it is the rollback source of truth)"
# ---------------------------------------------------------------- rollback ----
if [ "$ROLLBACK" = 1 ]; then
section "rollback: restore the Vercel CNAMEs and disable the redirect rule"
for n in "www.$ZONE_NAME" "$ZONE_NAME"; do
for t in CNAME A; do
id="$(cf_dns_find "$n" "$t")"
[ -n "$id" ] || continue
cf_dns_guard "$id" "$n"
dry "restore $n -> $VERCEL_DNS_TARGET (CNAME, unproxied, ttl 60)" -- true
[ "$DRY_RUN" = 0 ] && cf_api PUT "/zones/$ZID/dns_records/$id" \
"$(jq -n --arg n "$n" --arg c "$VERCEL_DNS_TARGET" \
'{type:"CNAME",name:$n,content:$c,proxied:false,ttl:60}')" >/dev/null
break
done
done
if [ -s "$STATE_DIR/state/redirect-rule.json" ]; then
rsid="$(jq -r .ruleset_id "$STATE_DIR/state/redirect-rule.json")"
rid="$(jq -r .rule_id "$STATE_DIR/state/redirect-rule.json")"
dry "disable redirect rule $rid" -- true
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/rulesets/$rsid/rules/$rid" \
'{"enabled":false}' >/dev/null || true
fi
echo
echo "The Pages project, its deployments and secrets are left intact — re-cutting"
echo "over later is just: 07-dns-cutover.sh --phase swap --apply"
echo "done: rolled back (www ttl=60)"
exit 0
fi
[ -n "$PHASE" ] || die "pass --phase prelower|probe|rules|hsts|swap|apex (or --rollback)"
# ---------------------------------------------------------------- prelower ----
phase_prelower() {
section "prelower: www TTL -> 60"
id="$(cf_dns_find "www.$ZONE_NAME" CNAME)"
[ -n "$id" ] || die "no www CNAME found"
cf_dns_guard "$id" "www.$ZONE_NAME"
cur="$(cf_api GET "/zones/$ZID/dns_records/$id")"
content="$(printf '%s' "$cur" | jq -r .result.content)"
ttl="$(printf '%s' "$cur" | jq -r .result.ttl)"
proxied="$(printf '%s' "$cur" | jq -r .result.proxied)"
echo " current: $content ttl=$ttl proxied=$proxied"
[ "$proxied" = "false" ] || die "record is proxied; TTL is not settable while proxied (and it should still be Vercel's at this point)"
dry "PATCH www TTL 60" -- true
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/dns_records/$id" '{"ttl":60}' >/dev/null
echo "done: www ttl 60 (was $ttl)"
}
# ------------------------------------------------------------------- probe ----
phase_probe() {
section "probe: can Pages attach a custom domain over an existing CNAME?"
echo "Uses the throwaway host $PROBE_HOST. www is never touched."
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: POST /zones/$ZID/dns_records (CNAME $PROBE_HOST -> $PAGES_PROJECT.pages.dev, proxied)"
echo "WOULD: POST /accounts/<acct>/pages/projects/$PAGES_PROJECT/domains {\"name\":\"$PROBE_HOST\"}"
echo "WOULD: poll until status=active, curl it, then DELETE both"
exit 0
fi
rid="$(cf_api POST "/zones/$ZID/dns_records" \
"$(jq -n --arg n "$PROBE_HOST" --arg c "$PAGES_PROJECT.pages.dev" \
'{type:"CNAME",name:$n,content:$c,proxied:true,ttl:1,comment:"cf-migrate probe - safe to delete"}')" \
| jq -r .result.id)"
echo " created probe record $rid"
attach_rc=0
cf_api POST "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains" \
"$(jq -n --arg n "$PROBE_HOST" '{name:$n}')" >/dev/null 2>&1 || attach_rc=$?
t0=$(date +%s); status=unknown
if [ "$attach_rc" = 0 ]; then
for _ in $(seq 1 60); do
status="$(cf_pages_domains | jq -r --arg n "$PROBE_HOST" '.result[]|select(.name==$n)|.status' | head -1)"
[ "$status" = active ] && break
sleep 5
done
fi
secs=$(( $(date +%s) - t0 ))
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://$PROBE_HOST/" 2>/dev/null || echo 000)"
echo " attach_rc=$attach_rc status=$status seconds_to_active=$secs http=$code"
if [ "$attach_rc" = 0 ] && [ "$status" = active ]; then
printf 'ok seconds_to_active=%s\n' "$secs" > "$STATE_DIR/state/attach-over-existing-record.ok"
rm -f "$STATE_DIR/state/attach-over-existing-record.unsupported"
echo " => in-place PATCH swap is supported (swap-mode auto will use it)"
else
printf 'unsupported attach_rc=%s status=%s\n' "$attach_rc" "$status" \
> "$STATE_DIR/state/attach-over-existing-record.unsupported"
rm -f "$STATE_DIR/state/attach-over-existing-record.ok"
warn "attach over an existing record did NOT work; swap-mode auto will fall back to delete-create"
warn "read the NODATA warning in this script's header before running the swap"
fi
cf_api DELETE "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains/$PROBE_HOST" >/dev/null 2>&1 || true
cf_dns_guard "$rid" "$PROBE_HOST"
cf_api DELETE "/zones/$ZID/dns_records/$rid" >/dev/null
echo "done: probe cleaned up"
}
# ------------------------------------------------------------------- rules ----
phase_rules() {
section "rules: apex -> www, 308, path + query preserved"
ep="$($CURL_BIN -sS "$CF_API/zones/$ZID/rulesets/phases/http_request_dynamic_redirect/entrypoint" \
-H "Authorization: Bearer $(cf_token)")"
have="$(printf '%s' "$ep" | jq -r '.success')"
rule="$(jq -n --arg h "$ZONE_NAME" --arg w "https://www.$ZONE_NAME" '{
ref: "cfm_apex_to_www",
description: "apex \($h) -> www (308, preserve query) [cf-migrate]",
expression: "(http.host eq \"\($h)\")",
action: "redirect",
action_parameters: { from_value: {
target_url: { expression: "concat(\"\($w)\", http.request.uri.path)" },
status_code: 308,
preserve_query_string: true } },
enabled: true }')"
# Exact host match only. A `contains` match would also catch app./demo./editor.
printf '%s' "$rule" | jq -e '.expression | contains("eq")' >/dev/null \
|| die "refusing a redirect expression that is not an exact host match"
if [ "$have" = "true" ]; then
rsid="$(printf '%s' "$ep" | jq -r '.result.id')"
n="$(printf '%s' "$ep" | jq '.result.rules | length // 0')"
echo " existing ruleset $rsid with $n rule(s) — APPENDING"
# POST /rules appends. A PUT on the entrypoint would replace the whole rule
# list and silently delete unrelated redirects.
[ "$n" -lt 10 ] || die "$n dynamic redirects already exist (free plan allows 10)"
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: POST /zones/$ZID/rulesets/$rsid/rules"; printf '%s\n' "$rule" | jq .
return 0
fi
out="$(cf_api POST "/zones/$ZID/rulesets/$rsid/rules" "$rule")"
else
echo " no dynamic-redirect ruleset yet — creating the phase entrypoint"
body="$(jq -n --argjson r "$rule" '{name:"Redirect rules ruleset",kind:"zone",phase:"http_request_dynamic_redirect",rules:[$r]}')"
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: POST /zones/$ZID/rulesets"; printf '%s\n' "$body" | jq .
return 0
fi
out="$(cf_api POST "/zones/$ZID/rulesets" "$body")"
rsid="$(printf '%s' "$out" | jq -r '.result.id')"
fi
rid="$(printf '%s' "$out" | jq -r '.result.rules[]? | select(.ref=="cfm_apex_to_www") | .id' | head -1)"
jq -n --arg rs "$rsid" --arg r "$rid" '{ruleset_id:$rs,rule_id:$r}' \
> "$STATE_DIR/state/redirect-rule.json"
echo " recorded ruleset=$rsid rule=$rid"
echo
echo "NOT verified yet, deliberately: the rule cannot fire until the apex record"
echo "is proxied. --phase apex asserts it."
echo "done: redirect rule created (inert)"
}
# -------------------------------------------------------------------- hsts ----
phase_hsts() {
section "hsts: max-age 63072000 (matching what Vercel sends today)"
cur="$(cf_api GET "/zones/$ZID/settings/security_header")"
printf '%s' "$cur" | jq -c '.result.value' 2>/dev/null || true
body='{"value":{"strict_transport_security":{"enabled":true,"max_age":63072000,"include_subdomains":false,"preload":false,"nosniff":true}}}'
# include_subdomains / preload are effectively irreversible (browsers cache the
# directive for the max-age, and preload lists are slow to leave). Refused.
echo " include_subdomains=false, preload=false — both deliberately off."
echo " Note this is ZONE-WIDE: cdn/app/demo/editor responses gain the header too."
echo " All are already HTTPS-only, so it is a no-op for them."
dry "PATCH zone HSTS" -- true
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/settings/security_header" "$body" >/dev/null
echo "done: HSTS enabled"
}
# -------------------------------------------------------------------- swap ----
phase_swap() {
section "swap: www.$ZONE_NAME -> Pages"
stamp_require 06-prod-deploy-parity
# Refuse to cut DNS onto a deployment other than the one that was verified.
want="$(jq -r '.deployment_id // "unknown"' "$STATE_DIR/stamps/06-prod-deploy-parity.$(ctx_hash)" 2>/dev/null \
|| grep -h '^deployment_id=' "$STATE_DIR/stamps/06-prod-deploy-parity.$(ctx_hash)" 2>/dev/null | cut -d= -f2)"
have="$(cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/deployments?per_page=1" \
| jq -r '.result[0].id // "none"')"
if [ -n "$want" ] && [ "$want" != unknown ] && [ "$want" != "$have" ]; then
die "verified deployment ($want) is not the current production deployment ($have).
Re-run 06-verify-deploy.sh --scope prod-deploy before cutting DNS."
fi
echo " verified deployment == current production deployment ($have)"
id="$(cf_dns_find "www.$ZONE_NAME" CNAME)"
[ -n "$id" ] || die "no www CNAME found"
cf_dns_guard "$id" "www.$ZONE_NAME"
cur="$(cf_api GET "/zones/$ZID/dns_records/$id" | jq -r .result.content)"
snap="$(jq -r --arg n "www.$ZONE_NAME" '.result[]|select(.name==$n and .type=="CNAME")|.content' "$SNAP" | head -1)"
[ "$cur" = "$snap" ] || die "www currently points at '$cur' but the snapshot says '$snap' — someone else changed it. Stop and re-check."
mode="$SWAP_MODE"
if [ "$mode" = auto ]; then
if [ -f "$STATE_DIR/state/attach-over-existing-record.ok" ]; then mode=update
else mode=delete-create
warn "no successful --phase probe result on record; falling back to delete-create"
warn "that risks NODATA negative-caching for the SOA minimum. Run --phase probe first."
fi
fi
echo " swap mode: $mode"
confirm_cutover
if [ "$mode" = update ]; then
dry "PATCH www -> $PAGES_PROJECT.pages.dev (proxied, atomic — no DNS gap)" -- true
[ "$DRY_RUN" = 0 ] && cf_api PATCH "/zones/$ZID/dns_records/$id" \
"$(jq -n --arg n "www.$ZONE_NAME" --arg c "$PAGES_PROJECT.pages.dev" \
'{type:"CNAME",name:$n,content:$c,proxied:true,ttl:1,comment:"Pages custom domain [cf-migrate]"}')" >/dev/null
else
dry "DELETE www record, then immediately attach (leaves a brief NODATA window)" -- true
[ "$DRY_RUN" = 0 ] && cf_api DELETE "/zones/$ZID/dns_records/$id" >/dev/null
fi
dry "POST pages custom domain www.$ZONE_NAME" -- true
if [ "$DRY_RUN" = 0 ]; then
cf_api POST "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains" \
"$(jq -n --arg n "www.$ZONE_NAME" '{name:$n}')" >/dev/null 2>&1 \
|| warn "attach returned an error — it may already be attached; checking status"
for _ in $(seq 1 60); do
s="$(cf_pages_domains | jq -r --arg n "www.$ZONE_NAME" '.result[]|select(.name==$n)|.status' | head -1)"
echo " domain status: ${s:-<absent>}"
[ "$s" = active ] && break
sleep 5
done
# Host-header probe: proves the edge routes the hostname to Pages regardless
# of what any resolver currently caches.
hp="$(curl -sS -o /dev/null -w '%{http_code}' -H "Host: www.$ZONE_NAME" \
"https://$PAGES_PROJECT.pages.dev/" 2>/dev/null || echo 000)"
echo " host-header probe via pages.dev: $hp"
fi
echo
echo "done: www swapped. Next: --phase apex, then 08-verify-prod.sh"
}
# -------------------------------------------------------------------- apex ----
phase_apex() {
section "apex: $ZONE_NAME -> proxied placeholder (activates the redirect rule)"
# Order matters: a proxied apex pointing at TEST-NET with no rule serves 522s.
[ -s "$STATE_DIR/state/redirect-rule.json" ] \
|| die "no redirect rule recorded — run --phase rules first, or the apex will 522"
rsid="$(jq -r .ruleset_id "$STATE_DIR/state/redirect-rule.json")"
rid="$(jq -r .rule_id "$STATE_DIR/state/redirect-rule.json")"
en="$(cf_api GET "/zones/$ZID/rulesets/$rsid" | jq -r --arg r "$rid" '.result.rules[]|select(.id==$r)|.enabled')"
[ "$en" = "true" ] || die "redirect rule $rid is not enabled — refusing to proxy the apex"
echo " redirect rule $rid is enabled"
id="$(cf_dns_find "$ZONE_NAME" CNAME)"
[ -n "$id" ] || id="$(cf_dns_find "$ZONE_NAME" A)"
[ -n "$id" ] || die "no apex record found"
cf_dns_guard "$id" "$ZONE_NAME"
before="$(cf_api GET "/zones/$ZID/dns_records/$id" | jq -c '{type:.result.type,content:.result.content,proxied:.result.proxied}')"
echo " before: $before"
# A single PUT changes CNAME -> A atomically; no delete/create gap.
dry "PUT apex -> A 192.0.2.0 proxied (placeholder; requests never reach it)" -- true
if [ "$DRY_RUN" = 1 ]; then echo; echo "done: (dry run)"; return 0; fi
cf_api PUT "/zones/$ZID/dns_records/$id" \
"$(jq -n --arg n "$ZONE_NAME" \
'{type:"A",name:$n,content:"192.0.2.0",proxied:true,ttl:1,comment:"placeholder for apex->www redirect rule [cf-migrate]"}')" >/dev/null
section "verifying the 308 (auto-rollback armed)"
ok=1
for _ in $(seq 1 12); do
if assert_apex_redirect "$APEX_BASE" >/dev/null 2>&1; then ok=0; break; fi
sleep 5
done
if [ "$ok" != 0 ]; then
warn "apex redirect did not come up — rolling the apex record back"
cf_api PUT "/zones/$ZID/dns_records/$id" \
"$(jq -n --arg n "$ZONE_NAME" --arg c "$VERCEL_DNS_TARGET" \
'{type:"CNAME",name:$n,content:$c,proxied:false,ttl:60}')" >/dev/null
die "apex rolled back to Vercel. Investigate the redirect rule, then retry."
fi
assert_apex_redirect "$APEX_BASE"
echo "done: apex 308s to www"
}
case "$PHASE" in
prelower) phase_prelower ;;
probe) phase_probe ;;
rules) phase_rules ;;
hsts) phase_hsts ;;
swap) phase_swap ;;
apex) phase_apex ;;
*) die "unknown phase '$PHASE' (prelower|probe|rules|hsts|swap|apex)" ;;
esac

87
deploy/site/08-verify-prod.sh Executable file
View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# The go/no-go after cutover. Read-only.
#
# deploy/site/08-verify-prod.sh
#
# Also asserts the two probes that FAILED in 00-baseline.sh now pass, so the
# migration demonstrably fixed the COOP/COEP bug rather than carrying it over.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/parity.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl dig jq awk sed
rc=0
section "DNS now resolves to Cloudflare"
www_cname="$(dig +short CNAME "www.$ZONE_NAME" || true)"
echo " www CNAME: ${www_cname:-<none>}"
case "$www_cname" in
*pages.dev*) echo " PASS points at Pages" ;;
*vercel-dns*) echo " FAIL still points at Vercel — DNS has not propagated (or the swap did not run)"; rc=1 ;;
*) echo " WARN unexpected target" ;;
esac
section "we are actually being served by Cloudflare, not a stale Vercel cache"
h="$(_headers "$PROD_BASE/")"
if [ -n "$(_hdr "$h" x-vercel-id)" ]; then
echo " FAIL x-vercel-id still present — you are seeing cached DNS."
echo " Wait for the TTL and re-run; this is NOT a pass."
rc=1
else
echo " PASS no x-vercel-id"
fi
[ -n "$(_hdr "$h" cf-ray)" ] && echo " PASS cf-ray present" || echo " WARN no cf-ray header"
assert_parity "$PROD_BASE" --scope prod || rc=$?
assert_apex_redirect "$APEX_BASE" || rc=$?
section "the two baseline failures must now pass"
# This is the whole point of having captured a baseline: prove the fix.
for path in /blog/porting-kicad-graphics-to-webgl-in-2026 /blog/porting-kicad-graphics-to-webgl-in-2026/; do
eff="$(_trace "$PROD_BASE$path" | cut -f1)"
hh="$(_headers "$eff")"
coop="$(_hdr "$hh" cross-origin-opener-policy)"; coep="$(_hdr "$hh" cross-origin-embedder-policy)"
if [ "$coop" = same-origin ] && [ "$coep" = require-corp ]; then
echo " PASS $path -> isolated ($eff)"
else
echo " FAIL $path -> coop='${coop:-absent}' coep='${coep:-absent}' ($eff)"
echo " this is the bug the migration was supposed to fix"
rc=1
fi
done
section "the demo integration (load-bearing)"
# demo.pcbjam.com has no backend; it cross-posts here. A CORS preflight cannot
# follow a redirect, so this must be 204 on www with zero hops.
hops="$(curl -sS -o /dev/null -w '%{num_redirects}' -X OPTIONS "$PROD_BASE/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null || echo 9)"
st="$(curl -sS -o /dev/null -w '%{http_code}' -X OPTIONS "$PROD_BASE/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' -H 'Access-Control-Request-Method: POST' 2>/dev/null || true)"
if [ "$st" = "204" ] && [ "$hops" = "0" ]; then
echo " PASS preflight 204 with 0 redirects"
else
echo " FAIL status=$st hops=$hops (both 204 and 0 hops are required)"; rc=1
fi
section "custom domain status"
dom="$(cf_pages_domains 2>/dev/null || true)"
if [ -n "$dom" ]; then
printf '%s' "$dom" | jq -r '.result[]? | " \(.name): \(.status)"'
printf '%s' "$dom" | jq -e --arg n "www.$ZONE_NAME" \
'[.result[]? | select(.name==$n and .status=="active")] | length > 0' >/dev/null 2>&1 \
&& echo " PASS www.$ZONE_NAME active" \
|| { echo " FAIL www.$ZONE_NAME not active"; rc=1; }
fi
section "summary"
if [ "$rc" -ne 0 ]; then
echo "PROD VERIFICATION FAILED."
echo "Rollback is one command: deploy/site/99-rollback.sh --apply"
exit 1
fi
stamp_write 08-prod-parity
echo "All checks passed. Vercel still holds the domains as a fallback;"
echo "detach them after a soak: deploy/site/09-detach-vercel.sh --apply"
echo "done: $PROD_BASE"

96
deploy/site/09-detach-vercel.sh Executable file
View file

@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Detach pcbjam.com + www.pcbjam.com from the Vercel project, AFTER a soak.
# Keeps the Vercel project, its env vars, and the _vercel TXT records — so
# re-attaching is instant and needs no re-verification.
#
# deploy/site/09-detach-vercel.sh # dry run
# deploy/site/09-detach-vercel.sh --apply
# deploy/site/09-detach-vercel.sh --rollback --apply # re-attach (incident use)
#
# Needs VERCEL_TOKEN (a team token with project write), or the vercel CLI logged in.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
require_cmd curl jq
SOAK_H="${SOAK_HOURS:-24}"; ROLLBACK=0
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--soak-hours) SOAK_H="$2"; shift 2 ;;
--rollback) ROLLBACK=1; shift ;;
--i-understand) CFM_I_UNDERSTAND=1; shift ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
dry_banner
VAPI="https://api.vercel.com"
vercel_api() { # vercel_api METHOD PATH
[ -n "${VERCEL_TOKEN:-}" ] || die "VERCEL_TOKEN is not set (a team token with project write)"
if [ "$1" != GET ] && [ "$DRY_RUN" = 1 ]; then
echo "WOULD: $1 $VAPI$2" >&2; echo '{"dry_run":true}'; return 0
fi
curl -sS -X "$1" "$VAPI$2" -H "Authorization: Bearer $VERCEL_TOKEN" -H 'Content-Type: application/json'
}
TEAM_ID="$(vercel_api GET "/v2/teams?slug=$VERCEL_TEAM" | jq -r '.teams[0].id // empty' 2>/dev/null || true)"
[ -n "$TEAM_ID" ] || warn "could not resolve team id for '$VERCEL_TEAM'; API calls may fail"
Q="teamId=$TEAM_ID"
if [ "$ROLLBACK" = 1 ]; then
section "re-attaching the domains to Vercel"
# Instant: the _vercel TXT verification records were never deleted.
for d in "$ZONE_NAME" "www.$ZONE_NAME"; do
dry "attach $d to Vercel project $VERCEL_PROJECT" -- true
[ "$DRY_RUN" = 0 ] && vercel_api POST "/v10/projects/$VERCEL_PROJECT/domains?$Q" >/dev/null 2>&1 || true
done
echo "done: domains re-attached (DNS still needs to point at Vercel — see 99-rollback.sh)"
exit 0
fi
section "soak gate"
STAMP="$STATE_DIR/stamps/08-prod-parity.$(ctx_hash)"
[ -f "$STAMP" ] || die "no 08-prod-parity stamp — run 08-verify-prod.sh first"
ts="$(awk -F= '/^ts=/{print $2}' "$STAMP")"
age_h=$(( ( $(date +%s) - $(stat -f %m "$STAMP") ) / 3600 ))
echo " verified at $ts (${age_h}h ago); soak requirement ${SOAK_H}h"
if [ "$age_h" -lt "$SOAK_H" ]; then
[ "${CFM_I_UNDERSTAND:-0}" = 1 ] \
|| die "only ${age_h}h since verification (need ${SOAK_H}h).
Vercel is your fallback — detaching early removes it. Pass --i-understand to override."
warn "soak window overridden"
fi
section "re-checking production before removing the fallback"
# Never detach while broken.
for u in "$PROD_BASE/" "$PROD_BASE/blog" "$PROD_BASE/pricing"; do
c="$(curl -sS -L -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000)"
[ "$c" = "200" ] || die "$u returned $c — refusing to detach Vercel while production is unhealthy"
echo " ok $u"
done
c="$(curl -sS -o /dev/null -w '%{http_code}' -X OPTIONS "$PROD_BASE/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' -H 'Access-Control-Request-Method: POST' 2>/dev/null || echo 000)"
[ "$c" = "204" ] || die "waitlist preflight returned $c — refusing to detach"
echo " ok waitlist preflight"
section "detaching"
for d in "www.$ZONE_NAME" "$ZONE_NAME"; do
dry "DELETE /v9/projects/$VERCEL_PROJECT/domains/$d" -- true
[ "$DRY_RUN" = 0 ] && vercel_api DELETE "/v9/projects/$VERCEL_PROJECT/domains/$d?$Q" >/dev/null
done
if [ "$DRY_RUN" = 0 ]; then
section "confirming"
left="$(vercel_api GET "/v9/projects/$VERCEL_PROJECT/domains?$Q" | jq -r '.domains[]?.name' | tr '\n' ' ')"
echo " domains still on the project: ${left:-<none>}"
case " $left " in
*" $ZONE_NAME "*|*" www.$ZONE_NAME "*) die "a target domain is still attached" ;;
esac
fi
section "done"
echo "Deliberately NOT done: the Vercel project, its env vars, and the two _vercel"
echo "TXT DNS records all remain. That is what keeps rollback cheap."
echo "done: vercel domains detached (project $VERCEL_PROJECT retained)"

72
deploy/site/99-rollback.sh Executable file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# One command, incident-grade: put www.pcbjam.com and the apex back on Vercel.
#
# deploy/site/99-rollback.sh # dry run — read it first
# deploy/site/99-rollback.sh --apply --yes
#
# Order matters: re-attach at Vercel FIRST so the target exists before DNS points
# at it, then restore DNS, then disable the redirect rule.
#
# Leaves the Pages project, its deployments and its secrets alone — re-cutting
# over later is just `07-dns-cutover.sh --phase swap --apply`.
set -euo pipefail
. "$(dirname "$0")/lib/common.sh"
. "$(dirname "$0")/lib/cf-api.sh"
require_cmd curl dig jq
SKIP_VERCEL=0
parse_common_flags "$@"
set -- $CFM_ARGS
while [ $# -gt 0 ]; do
case "$1" in
--skip-vercel) SKIP_VERCEL=1; shift ;;
"") shift ;;
*) die "unknown arg: $1" ;;
esac
done
dry_banner
HERE="$(dirname "$0")"
FLAGS=""; [ "$DRY_RUN" = 0 ] && FLAGS="--apply"
section "1/3 re-attach the domains at Vercel"
if [ "$SKIP_VERCEL" = 1 ]; then
echo " skipped (--skip-vercel)"
elif [ -z "${VERCEL_TOKEN:-}" ]; then
warn "VERCEL_TOKEN unset — skipping the re-attach step."
warn "Do it by hand NOW (Vercel -> project $VERCEL_PROJECT -> Domains -> add"
warn "$ZONE_NAME and www.$ZONE_NAME) before the DNS change below propagates."
else
# shellcheck disable=SC2086
"$HERE/09-detach-vercel.sh" --rollback $FLAGS || warn "re-attach reported an error; check the Vercel dashboard"
fi
section "2/3 restore DNS + disable the redirect rule"
# shellcheck disable=SC2086
"$HERE/07-dns-cutover.sh" --rollback $FLAGS --yes
if [ "$DRY_RUN" = 1 ]; then
echo; echo "done: (dry run) nothing changed. Re-run with --apply --yes to roll back."
exit 0
fi
section "3/3 waiting for Vercel to serve again (www ttl is 60)"
ok=1
for i in $(seq 1 60); do
h="$(curl -sS -o /dev/null -D - "$PROD_BASE/" 2>/dev/null | tr -d '\r' || true)"
st="$(printf '%s' "$h" | awk '/^HTTP/{c=$2} END{print c}')"
vid="$(printf '%s' "$h" | awk 'tolower($1)=="x-vercel-id:"{print $2}' | tail -1)"
echo " [${i}] status=$st x-vercel-id=${vid:-none} cname=$(dig +short CNAME "www.$ZONE_NAME" | head -1)"
if [ "$st" = "200" ] && [ -n "$vid" ]; then ok=0; break; fi
sleep 5
done
section "done"
if [ "$ok" = 0 ]; then
echo "www.$ZONE_NAME is served by Vercel again."
else
echo "www.$ZONE_NAME is NOT confirmably back on Vercel yet."
echo "Check: the domains are attached in Vercel, and dig www.$ZONE_NAME."
fi
echo "The Pages project is untouched; re-cutover is 07-dns-cutover.sh --phase swap --apply"
echo "done: rollback complete (re-baseline with 00-baseline.sh --force if you want a fresh reference)"

139
deploy/site/README.md Normal file
View file

@ -0,0 +1,139 @@
# www.pcbjam.com deploy runbook
The Astro marketing site + blog (`../../site`) on **Cloudflare Pages** (project
`pcbjam-site`), with one **Pages Function** for `/api/waitlist`. The apex
`pcbjam.com` 308s to `www` via a zone Redirect Rule.
```
push to main (site/**) ──▶ .github/workflows/deploy-site.yml
1. npm ci
2. npm test (vitest — nothing else runs it)
3. astro build → site/dist/ (static; no adapter)
4. wrangler pages deploy → www.pcbjam.com
5. smoke: /api/waitlist preflight == 204
```
Not tag-gated: content must not wait for a release. The WASM editor ships from
`release.yml`; the two are independent.
## Layout
```
site/functions/api/waitlist.ts the only server-side code (Pages Function)
site/public/_headers prod COOP/COEP, scoped to 2 routes
site/public/_routes.json only /api/* invokes the Function
site/wrangler.toml nodejs_compat + pages_build_output_dir
site/src/pages/404.astro required — see "soft-404" below
```
Two things you can get wrong here, both of which fail quietly:
- **Never widen `_headers` to `/*`.** `deploy/demo/_headers` does exactly that,
which is right for the demo and wrong here: a `require-corp` document cannot
load the no-COEP YouTube hero iframe, so the landing page must stay
un-isolated. The sweep asserts `/` is *not* isolated for this reason.
- **Never delete `404.astro`.** Without a `404.html` in the output, Pages answers
every unknown URL with the **homepage at HTTP 200** — a soft-404 that invites
search engines to index arbitrary URLs as the homepage.
## One-time setup (Cloudflare — needs your account)
1. `pcbjam.com` zone on Cloudflare; note the **account id**.
2. API token with: Zone→Zone:Read, Zone→DNS:Edit, Zone→Zone Settings:Edit,
Zone→Dynamic Redirect:Edit, Account→Cloudflare Pages:Edit.
Export `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.
3. Pages project `pcbjam-site`, production branch `production`
`03-ensure-project.sh --apply`.
4. Secrets — `04-set-secrets.sh --apply`. `WAITLIST_ALLOWED_ORIGINS` stays
**unset** so the allowlist lives in code.
5. Custom domain `www.pcbjam.com` + the apex Redirect Rule —
`07-dns-cutover.sh`. There is no `wrangler pages domain` subcommand, so this
goes through the API (or the dashboard).
6. The repo's GitHub secrets `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`
already exist for the demo/editor deploys — nothing to add.
## Migration / cutover (one time, from Vercel)
Every mutating script is **dry-run by default**; add `--apply`. Read the dry-run
output before applying — that is the whole point of the split.
| when | command | live? |
|---|---|---|
| Tdays | `00-baseline.sh` | no — read-only |
| Tdays | `01-preflight.sh` | no — read-only |
| Tdays | `07-dns-cutover.sh --phase probe --apply` | no — throwaway hostname |
| Tdays | `02-verify-local.sh` | no |
| Tdays | `03-ensure-project.sh --apply``04-set-secrets.sh --apply` | new project only |
| Tdays | `05-deploy.sh --preview --apply``06-verify-deploy.sh --latest` | no — pages.dev only |
| T1d | `07-dns-cutover.sh --phase rules --apply` | no — inert until the apex is proxied |
| T1d | `07-dns-cutover.sh --phase hsts --apply` | no — additive |
| T24h | `07-dns-cutover.sh --phase prelower --apply` | no — TTL only |
| T1h | `05-deploy.sh --production --apply``06-verify-deploy.sh --scope prod-deploy` | no — no DNS yet |
| **T+0** | `07-dns-cutover.sh --phase swap --apply` | **yes** |
| T+2m | `07-dns-cutover.sh --phase apex --apply` | yes (auto-rollback armed) |
| T+5m | `08-verify-prod.sh` | verify only |
| T+24h | `09-detach-vercel.sh --apply` | Vercel only |
Rollback, any time: **`99-rollback.sh --apply --yes`**. Keep it ready in a second
terminal during the swap.
### Why the swap is a PATCH, not a delete + create
A single `PATCH` flips the `www` record's content and proxied status atomically,
so there is **no DNS gap**. Delete-then-create leaves the name with no record for
a second or two, and any resolver that queries in that instant caches NODATA for
the zone's **SOA minimum** — typically 1800s. That is an un-flushable ~30-minute
partial outage. `00-baseline.sh` records the zone's actual value so the exposure
is a number, not a guess.
The residual risk with `PATCH` is HTTP-only and self-healing: for a second or two
the edge has no route for the hostname and serves the Pages not-found page.
Universal SSL already covers `*.pcbjam.com`, so TLS is never in question.
`--phase probe` settles whether Pages will attach a custom domain over an
existing CNAME, days early, on a throwaway hostname. `--swap-mode auto` reads
that result and only falls back to `delete-create` if it has to.
Throughout, Vercel stays attached, so resolvers still holding the old answer keep
serving the identical site. The cutover is a fade, not a switch.
## The parity sweep
`lib/parity.sh` is one assertion set, run against four bases: live Vercel
(baseline), `localhost:8788`, the `*.pages.dev` deployment, then `www`. Same
question every time, so a regression has nowhere to hide.
It asserts header values on the **final** response after following redirects,
via two separate requests (`_trace` then `_headers`). That is deliberate: the
COOP/COEP bug this migration fixes was invisible precisely because the headers
were present on a redirect hop and absent on the document.
`00-baseline.sh` is **expected to report failures** — the blog post's COOP/COEP
is genuinely broken on Vercel today (its canonical is the trailing-slash URL,
which serves 200 with no isolation headers). `08-verify-prod.sh` requires those
same probes to pass, which is how the fix is proven rather than assumed.
The honeypot POST is safe against production: that branch returns before
validation, before the rate limiter and before any Resend call, so it exercises
routing, Functions bundling, body parsing and CORS while sending no mail. The
only destructive probe (a *valid* email POST) is gated behind `--live-post` and
never runs against `www`.
## Gates
`02-verify-local.sh` writes a stamp keyed to a hash of `src/`, `public/`,
`functions/` and the configs. `05-deploy.sh` refuses to deploy without a stamp
for the *current* tree, and `07 --phase swap` refuses to cut DNS unless the
verified deployment id is still the live production deployment. Override with
`CFM_FORCE=1 CFM_I_UNDERSTAND=1`, which logs the bypass.
Scratch state (stamps, snapshots, logs) lives in `site/.cf-migrate/`, gitignored.
## Local development
```sh
cd site
npm run dev # Astro only — does NOT run functions/
cp .dev.vars.example .dev.vars # gitignored
npm run build && npm run pages:dev # http://localhost:8788, Function included
```

86
deploy/site/lib/cf-api.sh Normal file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Cloudflare API v4 wrapper + the DNS allowlist guard. Sourced, never executed.
#
# Required token scopes (one token, CLOUDFLARE_API_TOKEN):
# Zone -> Zone -> Read (pcbjam.com)
# Zone -> DNS -> Edit (record CRUD)
# Zone -> Zone Settings -> Edit (HSTS / security_header)
# Zone -> Dynamic Redirect-> Edit (http_request_dynamic_redirect ruleset)
# Account-> Cloudflare Pages-> Edit (project, secrets, deploy, custom domains)
CF_API="https://api.cloudflare.com/client/v4"
cf_token() {
[ -n "${CLOUDFLARE_API_TOKEN:-}" ] || die "CLOUDFLARE_API_TOKEN is not set"
printf '%s' "$CLOUDFLARE_API_TOKEN"
}
cf_account() {
[ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || die "CLOUDFLARE_ACCOUNT_ID is not set"
printf '%s' "$CLOUDFLARE_ACCOUNT_ID"
}
# cf_api METHOD PATH [JSON_BODY]
# GETs always execute (read-only). Non-GET honours DRY_RUN and prints the exact
# call it would make, body included, so a reviewer sees it before it happens.
cf_api() {
_m="$1"; _p="$2"; _b="${3:-}"
if [ "$_m" != "GET" ] && [ "${DRY_RUN:-1}" = 1 ]; then
echo "WOULD: $_m $_p" >&2
[ -n "$_b" ] && printf '%s\n' "$_b" | jq . >&2
echo '{"success":true,"result":{},"dry_run":true}'
return 0
fi
_log="$STATE_DIR/logs/api-$(date +%s)-$$.json"
if [ -n "$_b" ]; then
_out="$($CURL_BIN -sS -X "$_m" "$CF_API$_p" \
-H "Authorization: Bearer $(cf_token)" \
-H 'Content-Type: application/json' --data "$_b")"
else
_out="$($CURL_BIN -sS -X "$_m" "$CF_API$_p" \
-H "Authorization: Bearer $(cf_token)" \
-H 'Content-Type: application/json')"
fi
printf '%s' "$_out" > "$_log"
printf '%s' "$_out" | jq -e '.success == true' >/dev/null 2>&1 \
|| die "API $_m $_p failed — see $_log
$(printf '%s' "$_out" | jq -r '.errors[]? | " [\(.code)] \(.message)"' 2>/dev/null)"
printf '%s' "$_out"
}
CURL_BIN="${CURL_BIN:-curl}"
cf_zone_id() {
if [ -s "$STATE_DIR/state/zone-id" ]; then cat "$STATE_DIR/state/zone-id"; return; fi
_r="$(cf_api GET "/zones?name=$ZONE_NAME")"
_n="$(printf '%s' "$_r" | jq '.result | length')"
[ "$_n" = "1" ] || die "expected exactly 1 zone named $ZONE_NAME, found $_n"
printf '%s' "$_r" | jq -r '.result[0].id' | tee "$STATE_DIR/state/zone-id"
}
cf_dns_list() { cf_api GET "/zones/$(cf_zone_id)/dns_records?per_page=500"; }
# cf_dns_find <name> <type> -> record id ("" if none)
cf_dns_find() {
cf_dns_list | jq -r --arg n "$1" --arg t "$2" \
'.result[] | select(.name==$n and .type==$t) | .id' | head -1
}
# THE guard. Every DNS mutation passes through this first. The zone also holds
# app/demo/editor/cdn/assets records, the Google MX and the two _vercel TXT
# verification records — none of which this migration may touch.
cf_dns_guard() { # cf_dns_guard <record_id> <expected_name>
_r="$(cf_api GET "/zones/$(cf_zone_id)/dns_records/$1")"
_n="$(printf '%s' "$_r" | jq -r .result.name)"
_t="$(printf '%s' "$_r" | jq -r .result.type)"
case "$_n" in
"$ZONE_NAME"|"www.$ZONE_NAME"|"cf-attach-probe.$ZONE_NAME") : ;;
*) die "refusing to touch DNS record '$_n' (allowlist: apex, www, cf-attach-probe)" ;;
esac
[ "$_n" = "$2" ] || die "record $1 is '$_n', expected '$2' (stale id? re-run 01-preflight.sh)"
case "$_t" in
A|CNAME) : ;;
*) die "refusing to touch a $_t record ($_n) — only A/CNAME are in scope" ;;
esac
}
cf_pages_project() { cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT"; }
cf_pages_domains() { cf_api GET "/accounts/$(cf_account)/pages/projects/$PAGES_PROJECT/domains"; }

136
deploy/site/lib/common.sh Normal file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Shared plumbing for the deploy/site cf-migrate scripts: paths, logging,
# dry-run, stamps and guards. Sourced, never executed.
#
# bash 3.2 compatible (/usr/bin/env bash on macOS is 3.2.57): no associative
# arrays, no `mapfile`, no ${x,,}. Accumulators use temp files.
# Repo + site paths. Every script cd's to SITE_DIR before building or deploying:
# `wrangler pages deploy` discovers Functions at $PWD/functions, so running from
# the wrong directory silently ships a static-only site with /api/waitlist 404ing.
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_SITE_DIR="$(dirname "$LIB_DIR")"
PCBJAM_DIR="$(cd "$DEPLOY_SITE_DIR/../.." && pwd)"
SITE_DIR="$PCBJAM_DIR/site"
STATE_DIR="$SITE_DIR/.cf-migrate"
# Defaults, overridable by env.
PAGES_PROJECT="${PAGES_PROJECT:-pcbjam-site}"
PAGES_PROD_BRANCH="${PAGES_PROD_BRANCH:-production}"
ZONE_NAME="${ZONE_NAME:-pcbjam.com}"
PROD_BASE="${PROD_BASE:-https://www.pcbjam.com}"
APEX_BASE="${APEX_BASE:-https://pcbjam.com}"
VERCEL_PROJECT="${VERCEL_PROJECT:-pcbjam}"
VERCEL_TEAM="${VERCEL_TEAM:-pcbj-am}"
VERCEL_DNS_TARGET="${VERCEL_DNS_TARGET:-dcfb2907091b7240.vercel-dns-016.com}"
WRANGLER="${WRANGLER_CMD:-npx --yes wrangler@4}"
mkdir -p "$STATE_DIR/state" "$STATE_DIR/stamps" "$STATE_DIR/baseline" "$STATE_DIR/logs"
section() { echo; echo "== $* =="; }
warn() { echo "warn: $*" >&2; }
die() { echo "cf-migrate: $*" >&2; exit 1; }
require_cmd() {
for c in "$@"; do
command -v "$c" >/dev/null 2>&1 || die "missing required command: $c"
done
}
# Refuse to run under `set -x` where secrets would land in the log.
no_xtrace() {
case "$-" in
*x*) die "refusing to run under 'set -x' (secrets would be echoed)" ;;
esac
}
# Identity of "the thing that was verified" — the inputs that affect the built
# output. Stamps are keyed on this so a stamp can never vouch for a later edit.
ctx_hash() {
( cd "$SITE_DIR" && \
find src public functions astro.config.mjs package.json package-lock.json \
wrangler.toml -type f 2>/dev/null \
| LC_ALL=C sort | tr '\n' '\0' | xargs -0 shasum -a 256 2>/dev/null \
| shasum -a 256 | cut -c1-16 )
}
stamp_write() {
_name="$1"; shift
_f="$STATE_DIR/stamps/${_name}.$(ctx_hash)"
{
echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "ctx=$(ctx_hash)"
echo "git=$(git -C "$PCBJAM_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown)"
for kv in "$@"; do echo "$kv"; done
} > "$_f"
echo "stamped: ${_name} (ctx=$(ctx_hash))"
}
# Dies unless a stamp for the CURRENT ctx_hash exists and is fresh.
# CFM_FORCE=1 downgrades to a loud warning; it also requires CFM_I_UNDERSTAND=1.
stamp_require() {
_name="$1"; _max_h="${2:-${STAMP_MAX_AGE_H:-24}}"
_f="$STATE_DIR/stamps/${_name}.$(ctx_hash)"
if [ ! -f "$_f" ]; then
if [ "${CFM_FORCE:-0}" = 1 ] && [ "${CFM_I_UNDERSTAND:-0}" = 1 ]; then
warn "BYPASS: no '${_name}' stamp for the current tree, continuing anyway"
return 0
fi
die "no '${_name}' stamp for the current tree (ctx=$(ctx_hash)).
The site changed since it was last verified. Run the matching script first:
${_name} -> see deploy/site/README.md
To override (not advised): CFM_FORCE=1 CFM_I_UNDERSTAND=1 $0 ..."
fi
_age=$(( ( $(date +%s) - $(stat -f %m "$_f" 2>/dev/null || echo 0) ) / 3600 ))
if [ "$_age" -ge "$_max_h" ]; then
if [ "${CFM_FORCE:-0}" = 1 ] && [ "${CFM_I_UNDERSTAND:-0}" = 1 ]; then
warn "BYPASS: '${_name}' stamp is ${_age}h old (max ${_max_h}h), continuing"
return 0
fi
die "'${_name}' stamp is ${_age}h old (max ${_max_h}h) — re-verify."
fi
}
# --- dry run ---------------------------------------------------------------
# DRY_RUN=1 is the DEFAULT for every mutating script. `--apply` clears it.
DRY_RUN="${DRY_RUN:-1}"
parse_common_flags() {
CFM_ARGS=""
for a in "$@"; do
case "$a" in
--apply) DRY_RUN=0 ;;
--dry-run) DRY_RUN=1 ;;
--yes) CFM_YES=1 ;;
*) CFM_ARGS="$CFM_ARGS $a" ;;
esac
done
}
# dry "<label>" -- cmd... → prints WOULD in dry-run, else runs.
dry() {
_label="$1"; shift
[ "$1" = "--" ] && shift
if [ "$DRY_RUN" = 1 ]; then
echo "WOULD: $_label"
echo " \$ $*"
return 0
fi
echo "RUN: $_label"
"$@"
}
confirm_cutover() {
[ "${CFM_YES:-0}" = 1 ] && return 0
echo
echo "This mutates PRODUCTION DNS for $ZONE_NAME."
printf 'Type CUTOVER to proceed: '
read -r _reply
[ "$_reply" = "CUTOVER" ] || die "aborted (got '$_reply')"
}
dry_banner() {
if [ "$DRY_RUN" = 1 ]; then
echo "(dry run — nothing will be changed. re-run with --apply to act.)"
fi
}

282
deploy/site/lib/parity.sh Normal file
View file

@ -0,0 +1,282 @@
#!/usr/bin/env bash
# The one HTTP parity sweep. Sourced by the verify scripts and run against four
# bases, so "did anything change?" is always the same question:
#
# https://www.pcbjam.com live Vercel (baseline)
# http://127.0.0.1:8788 wrangler pages dev
# https://<hash>.pcbjam-site.pages.dev the Pages deployment, pre-DNS
# https://www.pcbjam.com after cutover
#
# Usage: assert_parity <base-url> [--scope local|preview|prod] [--live-post]
# Exit: 0 if every hard assertion passed (warnings do not fail), else 1.
CURL="curl -sS --max-time 20"
# --- the two primitives ----------------------------------------------------
# Deliberately split. _trace follows redirects to learn WHERE we land; _headers
# then re-fetches that exact URL WITHOUT -L so there is exactly one response to
# parse. Asserting on a multi-block `-IL` dump is precisely how the COOP/COEP
# regression stayed hidden on Vercel: the headers were on the redirect hop, not
# on the document.
_trace() { # _trace URL -> "eff_url<TAB>status<TAB>hops"
$CURL -L -o /dev/null -w '%{url_effective}\t%{http_code}\t%{num_redirects}' "$1" 2>/dev/null
}
_headers() { # _headers URL [extra curl args...] -> "name: value" lines, name lowercased
$CURL -D - -o /dev/null "$@" 2>/dev/null | tr -d '\r' | awk '
/^HTTP\/[0-9.]+ [0-9][0-9][0-9]/ { buf=""; next } # reset on each block; keep the last
NF==0 { next }
{ i=index($0,":"); if(i){ k=tolower(substr($0,1,i-1)); v=substr($0,i+1);
sub(/^[ \t]+/,"",v); buf = buf k ": " v "\n" } }
END { printf "%s", buf }'
}
_hdr() { # _hdr "<headers>" name -> value ("" if absent)
printf '%s\n' "$1" | awk -v k="$2: " 'index($0,k)==1 { print substr($0, length(k)+1); exit }'
}
_title() { $CURL -L "$1" 2>/dev/null | tr -d '\n' | sed -n 's/.*<title>\([^<]*\)<\/title>.*/\1/p'; }
# --- result accumulation (bash 3.2: temp file, not an array) ---------------
_P_RESULTS=""
_p_init() { _P_RESULTS="$(mktemp -t cfmparity)"; }
_p_add() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$_P_RESULTS"; }
pass() { _p_add PASS "$1" "${2:-}" "${3:-}"; }
fail() { _p_add FAIL "$1" "${2:-}" "${3:-}"; }
soft() { _p_add WARN "$1" "${2:-}" "${3:-}"; }
# assert_status <probe> <url> <expected>
_expect_page() { # _expect_page <probe> <base> <path>
_pr="$1"; _b="$2"; _path="$3"
_t="$(_trace "$_b$_path")"
_eff="$(printf '%s' "$_t" | cut -f1)"; _st="$(printf '%s' "$_t" | cut -f2)"
_hops="$(printf '%s' "$_t" | cut -f3)"
_effpath="$(printf '%s' "$_eff" | sed -e 's|^[a-z]*://[^/]*||' -e 's|?.*$||')"
_h="$(_headers "$_eff")"
_ct="$(_hdr "$_h" content-type)"
# Hard: final 200, landed on the requested path (+/- a trailing slash), HTML.
# Trailing-slash HOPS are recorded, never failed on: Vercel serves both forms
# at 200; Pages 308s the bare form to the slash form. Both are fine. What is
# NOT fine is landing somewhere else — that is how a soft-404 shows up.
if [ "$_st" != "200" ]; then fail "$_pr" "$_hops" "expected 200, got $_st"; return; fi
case "$_effpath" in
"$_path"|"$_path/") : ;;
*) fail "$_pr" "$_hops" "landed on '$_effpath', expected '$_path'"; return ;;
esac
case "$_ct" in
text/html*) : ;;
*) fail "$_pr" "$_hops" "content-type '$_ct'"; return ;;
esac
pass "$_pr" "$_hops" "200 $_effpath"
}
_expect_coi() { # _expect_coi <probe> <base> <path> (cross-origin isolated)
_pr="$1"; _b="$2"; _path="$3"
_t="$(_trace "$_b$_path")"; _eff="$(printf '%s' "$_t" | cut -f1)"
_hops="$(printf '%s' "$_t" | cut -f3)"
_h="$(_headers "$_eff")"
_coop="$(_hdr "$_h" cross-origin-opener-policy)"
_coep="$(_hdr "$_h" cross-origin-embedder-policy)"
if [ "$_coop" = "same-origin" ] && [ "$_coep" = "require-corp" ]; then
pass "$_pr" "$_hops" "COOP+COEP on $_eff"
else
fail "$_pr" "$_hops" "coop='${_coop:-absent}' coep='${_coep:-absent}' on $_eff"
fi
}
_expect_not_coi() { # the landing page MUST NOT be isolated
_pr="$1"; _b="$2"; _path="$3"
_t="$(_trace "$_b$_path")"; _eff="$(printf '%s' "$_t" | cut -f1)"
_h="$(_headers "$_eff")"
_coep="$(_hdr "$_h" cross-origin-embedder-policy)"
if [ -z "$_coep" ]; then
pass "$_pr" "-" "not isolated (correct — YouTube hero embed)"
else
fail "$_pr" "-" "COEP '$_coep' present; a require-corp landing page cannot load the YouTube embed"
fi
}
assert_parity() {
_base="${1:?usage: assert_parity <base-url> [--scope local|preview|prod] [--live-post]}"
shift
_scope=preview; _live_post=0
while [ $# -gt 0 ]; do
case "$1" in
--scope) _scope="$2"; shift 2 ;;
--live-post) _live_post=1; shift ;;
*) shift ;;
esac
done
_base="$(printf '%s' "$_base" | sed 's|/*$||')"
_p_init
section "parity sweep: $_base (scope=$_scope)"
# 1) pages
for p in / /pricing /blog /privacy /terms /cookies /licenses; do
_n="$(printf '%s' "$p" | sed 's|^/||')"; [ -z "$_n" ] && _n=home
_expect_page "$_n" "$_base" "$p"
done
# 2) cross-origin isolation, asserted on the FINAL response.
# BOTH URL forms of the blog post are probed on purpose. Vercel serves the bare
# form at 200 with the headers and the TRAILING-SLASH form at 200 WITHOUT them —
# and the trailing-slash form is the page's own canonical, i.e. what search
# sends people to. So post_coi passes on Vercel while post_coi_slash fails; that
# asymmetry IS the bug, and probing only one form would hide it.
_expect_coi post_coi "$_base" /blog/porting-kicad-graphics-to-webgl-in-2026
_expect_coi post_coi_slash "$_base" /blog/porting-kicad-graphics-to-webgl-in-2026/
_expect_coi gerber_boot "$_base" /gerber-demo/boot.js
_expect_not_coi landing_iso "$_base" /
# 3) immutable asset caching
_asset="$($CURL -L "$_base/" 2>/dev/null | tr '"' '\n' | grep -m1 '^/_astro/[^ ]*\.css$' || true)"
if [ -n "$_asset" ]; then
_cc="$(_hdr "$(_headers "$_base$_asset")" cache-control)"
case "$_cc" in
*immutable*) pass astro_cache - "$_cc" ;;
*) soft astro_cache - "cache-control='${_cc:-absent}'" ;;
esac
else
soft astro_cache - "no hashed css found on /"
fi
# 4) the waitlist endpoint
# Preflight from the allowlisted demo origin. hops MUST be 0: a CORS preflight
# cannot follow a redirect, so this is the check that guards demo.pcbjam.com's
# entire waitlist integration.
_pre="$($CURL -o /dev/null -D - -X OPTIONS "$_base/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type' 2>/dev/null | tr -d '\r')"
_pre_st="$(printf '%s' "$_pre" | awk '/^HTTP/{c=$2} END{print c}')"
_pre_h="$(_headers "$_base/api/waitlist" -X OPTIONS -H 'Origin: https://demo.pcbjam.com')"
_acao="$(_hdr "$_pre_h" access-control-allow-origin)"
_hops0="$($CURL -o /dev/null -w '%{num_redirects}' -X OPTIONS "$_base/api/waitlist" \
-H 'Origin: https://demo.pcbjam.com' 2>/dev/null)"
if [ "$_pre_st" = "204" ] && [ "$_acao" = "https://demo.pcbjam.com" ] && [ "$_hops0" = "0" ]; then
pass api_preflight "$_hops0" "204 acao=$_acao"
else
fail api_preflight "$_hops0" "status=$_pre_st acao='${_acao:-absent}' hops=$_hops0 (all of 204/echoed-origin/0-hops required)"
fi
_deny_h="$(_headers "$_base/api/waitlist" -X OPTIONS -H 'Origin: https://not-allowed.example')"
if [ -z "$(_hdr "$_deny_h" access-control-allow-origin)" ]; then
pass api_cors_deny - "no CORS for a non-allowlisted origin"
else
fail api_cors_deny - "CORS granted to https://not-allowed.example"
fi
_gst="$($CURL -o /dev/null -w '%{http_code}' "$_base/api/waitlist" 2>/dev/null)"
[ "$_gst" = "405" ] && pass api_get - "405" || fail api_get - "expected 405, got $_gst"
_bad="$($CURL -o /dev/null -w '%{http_code}' -X POST "$_base/api/waitlist" \
-H 'content-type: application/json' --data '{"email":"nope"}' 2>/dev/null)"
[ "$_bad" = "400" ] && pass api_invalid_email - "400" || fail api_invalid_email - "expected 400, got $_bad"
# The honeypot branch returns BEFORE validation, BEFORE the rate limiter and
# BEFORE any Resend call, so this exercises the whole request path (routing,
# Functions bundling, body parsing, CORS) with zero side effects — safe to run
# against production.
_hp="$($CURL -X POST "$_base/api/waitlist" -H 'content-type: application/json' \
-H 'Origin: https://demo.pcbjam.com' \
--data '{"email":"parity@pcbjam.com","company_url":"bot","source":"cfm-parity"}' \
-w '\n%{http_code}' 2>/dev/null)"
_hp_st="$(printf '%s' "$_hp" | tail -1)"
case "$_hp" in
*'"ok":true'*) [ "$_hp_st" = "200" ] && pass api_honeypot - "200 ok:true" \
|| fail api_honeypot - "ok:true but status $_hp_st" ;;
*) fail api_honeypot - "status=$_hp_st body did not contain ok:true" ;;
esac
# No-JS native form submit -> 303 back to the page. The same-origin Origin
# header is required, not cosmetic: a real browser form submit sends it, and
# both Vercel's edge and (post-migration) the Function itself refuse a
# form-encoded POST that carries a foreign Origin. Omitting it here gets a 403
# and looks like a broken endpoint.
_form="$($CURL -o /dev/null -D - -X POST "$_base/api/waitlist" \
-H "Origin: $_base" \
--data-urlencode 'email=parity@pcbjam.com' \
--data-urlencode 'company_url=bot' 2>/dev/null | tr -d '\r')"
_form_st="$(printf '%s' "$_form" | awk '/^HTTP/{c=$2} END{print c}')"
_loc="$(printf '%s' "$_form" | awk 'tolower($1)=="location:"{print $2}' | tail -1)"
if [ "$_form_st" = "303" ] && [ "$_loc" = "/?waitlist=ok#waitlist" ]; then
pass api_form_303 - "303 -> $_loc"
else
fail api_form_303 - "status=$_form_st location='${_loc:-absent}'"
fi
# Cross-site form POST must be refused. Vercel's edge did this for free; the
# Function reproduces it, so this probe must pass on BOTH platforms.
_csrf="$($CURL -o /dev/null -w '%{http_code}' -X POST "$_base/api/waitlist" \
-H 'Origin: https://evil.example' \
--data-urlencode 'email=parity@pcbjam.com' 2>/dev/null || true)"
[ "$_csrf" = "403" ] && pass api_form_csrf - "403 cross-site form POST refused" \
|| fail api_form_csrf - "expected 403, got $_csrf (cross-site form POST is a CSRF vector)"
if [ "$_live_post" = 1 ]; then
_live="$($CURL -X POST "$_base/api/waitlist" -H 'content-type: application/json' \
--data '{"email":"cfm-live@example.com","source":"cfm-parity"}' \
-w '\n%{http_code}' 2>/dev/null)"
_live_st="$(printf '%s' "$_live" | tail -1)"
# 200 = accepted (no key configured); 502 = the Resend SDK loaded and the API
# rejected our (bogus) key. Both prove the module resolved under workerd.
case "$_live_st" in
200|502) pass api_live_post - "status=$_live_st (SDK loaded)" ;;
*) fail api_live_post - "expected 200 or 502, got $_live_st — likely a module-resolution error (nodejs_compat?)" ;;
esac
fi
# 5) a real 404, not the homepage at 200
_nf_url="$_base/__cfm-parity-404__/"
_nf_st="$($CURL -L -o /dev/null -w '%{http_code}' "$_nf_url" 2>/dev/null)"
_nf_title="$(_title "$_nf_url")"
_home_title="$(_title "$_base/")"
if [ "$_nf_st" != "404" ]; then
fail notfound - "expected 404, got $_nf_st (a 200 here is a soft-404 serving the homepage)"
elif [ -n "$_home_title" ] && [ "$_nf_title" = "$_home_title" ]; then
fail notfound - "404 status but the homepage document was served"
else
pass notfound - "404 '$_nf_title'"
fi
# 6) prod-only
if [ "$_scope" = prod ]; then
_hsts="$(_hdr "$(_headers "$_base/")" strict-transport-security)"
case "$_hsts" in
*max-age=63072000*) pass hsts - "$_hsts" ;;
"") fail hsts - "absent (Vercel sent max-age=63072000)" ;;
*) soft hsts - "$_hsts (differs from the Vercel baseline)" ;;
esac
fi
# --- report -------------------------------------------------------------
printf '\n%-6s %-18s %-5s %s\n' STATUS PROBE HOPS DETAIL
awk -F'\t' '{ printf "%-6s %-18s %-5s %s\n", $1, $2, ($3==""?"-":$3), $4 }' "$_P_RESULTS"
_np=$(grep -c '^PASS' "$_P_RESULTS" || true)
_nf=$(grep -c '^FAIL' "$_P_RESULTS" || true)
_nw=$(grep -c '^WARN' "$_P_RESULTS" || true)
echo
echo "$(( _np + _nf + _nw )) probes: ${_np} pass, ${_nf} fail, ${_nw} warn"
rm -f "$_P_RESULTS"
if [ "$_nf" -gt 0 ]; then echo "parity: FAIL (${_nf})"; return 1; fi
echo "parity: PASS"; return 0
}
# Apex -> www redirect, path + query preserved. Prod only.
assert_apex_redirect() {
_apex="${1:-$APEX_BASE}"
section "apex redirect: $_apex"
_rc=0
for pair in "/:https://www.pcbjam.com/" "/pricing?a=1&b=2:https://www.pcbjam.com/pricing?a=1&b=2"; do
_path="${pair%%:*}"; _want="${pair#*:}"
_h="$($CURL -o /dev/null -D - "$_apex$_path" 2>/dev/null | tr -d '\r')"
_st="$(printf '%s' "$_h" | awk '/^HTTP/{c=$2} END{print c}')"
_loc="$(printf '%s' "$_h" | awk 'tolower($1)=="location:"{print $2}' | tail -1)"
if [ "$_st" = "308" ] && [ "$_loc" = "$_want" ]; then
echo "PASS $_apex$_path -> 308 $_loc"
else
echo "FAIL $_apex$_path -> status=$_st location='${_loc:-absent}' (wanted 308 $_want)"; _rc=1
fi
done
return $_rc
}