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

120
.github/workflows/deploy-site.yml vendored Normal file
View file

@ -0,0 +1,120 @@
name: deploy-site (marketing site)
# Deploys the Astro marketing site + blog in site/ to Cloudflare Pages
# (www.pcbjam.com, Pages project `pcbjam-site`).
#
# push to main touching site/** ──▶ npm ci → npm test → astro build
# → wrangler pages deploy → www.pcbjam.com
#
# NOT tag-gated, deliberately. The site was previously deployed by Vercel's git
# integration on every push, and blog posts / copy fixes must not have to wait
# for a vX.Y.Z release. The tag-gated pipeline (release.yml) ships the WASM
# editor; this ships content, and the two are independent.
#
# The site is a STANDALONE npm project (its own package-lock.json, not the
# web/ pnpm workspace) and needs Node >= 22.12 for Astro 6 — hence npm ci and
# node-version 22 rather than the pnpm + node 20 used by the other workflows.
#
# The one dynamic route, /api/waitlist, ships as a Cloudflare Pages Function
# from site/functions/. Its secrets (RESEND_API_KEY, RESEND_SEGMENT_ID,
# WAITLIST_FROM_EMAIL) are NOT deploy inputs: set once with
# `wrangler pages secret put <NAME> --project-name pcbjam-site`.
# See deploy/site/README.md for the full runbook.
#
# Secrets (Settings → Secrets → Actions) — already present for demo/editor:
# CLOUDFLARE_API_TOKEN Cloudflare Pages:Edit
# CLOUDFLARE_ACCOUNT_ID
on:
push:
branches: ["main"]
paths: ["site/**", ".github/workflows/deploy-site.yml"]
workflow_dispatch:
# Serialize site deploys so two pushes don't race the live host (don't cancel a
# half-finished deploy — let it complete).
concurrency:
group: deploy-site
cancel-in-progress: false
env:
PAGES_PROJECT: pcbjam-site
# MUST be the Pages project's PRODUCTION branch — any other value makes
# `wrangler pages deploy` a PREVIEW deploy and www.pcbjam.com won't update.
# Direct-Upload projects default to "production".
PAGES_PROD_BRANCH: production
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# No submodules: the site shares no code with the WASM tools. A real
# checkout is still needed — Footer.astro resolves the GPLv3
# corresponding-source SHA from GITHUB_SHA (with a `git rev-parse`
# fallback), and that value is user-visible in the footer.
- uses: actions/checkout@v4
with:
submodules: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: site/package-lock.json
- name: Install site deps
working-directory: site
run: npm ci
# 1) Gate on the site's own vitest suite (waitlist Function hardening +
# the gerber-demo boot.js override gate). Nothing else runs it.
- name: Test
working-directory: site
run: npm test
# 2) Static build. Emits dist/ only — no adapter, no server bundle. The
# Function comes from site/functions/, which wrangler bundles at deploy.
- name: Build
working-directory: site
run: npm run build
# 3) Ensure the Pages project exists (first deploy creates it; no-op
# after). Its production branch must equal PAGES_PROD_BRANCH or deploys
# land as previews and www.pcbjam.com won't update.
- name: Ensure Pages project exists
working-directory: site
run: >
npx --yes wrangler@4 pages project create "$PAGES_PROJECT"
--production-branch "$PAGES_PROD_BRANCH"
--compatibility-date 2026-06-01 --compatibility-flags nodejs_compat
|| echo "pages project create skipped (already exists)"
# 4) Deploy. Run from site/ so wrangler picks up site/wrangler.toml (which
# sets pages_build_output_dir + nodejs_compat) AND discovers
# site/functions/ — deploying from the repo root would silently ship a
# static-only site with /api/waitlist 404ing.
- name: Deploy to Cloudflare Pages
working-directory: site
run: >
npx --yes wrangler@4 pages deploy
--project-name "$PAGES_PROJECT"
--branch "$PAGES_PROD_BRANCH"
--commit-dirty=true
# 5) Smoke: the deployed Function answers a preflight for the demo origin.
# demo.pcbjam.com cross-posts the waitlist form here and a CORS
# preflight cannot follow a redirect, so this must be 204 directly on
# www — not after a hop.
- name: Smoke-check the waitlist endpoint
run: |
for i in $(seq 1 20); do
code=$(curl -s -o /dev/null -w '%{http_code}' -X OPTIONS \
-H 'Origin: https://demo.pcbjam.com' \
-H 'Access-Control-Request-Method: POST' \
https://www.pcbjam.com/api/waitlist || true)
[ "$code" = "204" ] && break; sleep 3
done
test "$code" = "204" || { echo "waitlist preflight returned $code, expected 204"; exit 1; }

View file

@ -28,4 +28,15 @@ Don't try to guess what's broken , use debug tools / symbols, supported by the b
Feature docs/patches are in features/<branch-name>/. Run scripts/create-feature-patches.sh to save patches for root, kicad, wxwidgets submodules.
The landing page / website is in /site (Astro, built and deployed by Vercel on push). The footer shows a build SHA that links to the pcbjam commit the site was built from; because it pins the kicad + wxwidgets submodule revisions implicitly, it is our GPLv3 corresponding-source pointer (see /licenses). It resolves automatically at build time in site/src/components/Footer.astro (VERCEL_GIT_COMMIT_SHA on Vercel, `git rev-parse` locally) — no manual bump needed.
The landing page / website is in /site (Astro, static, deployed to Cloudflare Pages
by .github/workflows/deploy-site.yml on every push to main touching site/**).
It has no Astro adapter; the one dynamic route (/api/waitlist) is a Cloudflare
Pages Function in site/functions/. Prod response headers come from
site/public/_headers (COOP/COEP for the embedded Gerber viewer — never widen
them to /*, the landing page must stay un-isolated for the YouTube embed).
The footer shows a build SHA that links to the pcbjam commit the site was built
from; because it pins the kicad + wxwidgets submodule revisions implicitly, it is
our GPLv3 corresponding-source pointer (see /licenses). It resolves automatically
at build time in site/src/components/Footer.astro (CF_PAGES_COMMIT_SHA / GITHUB_SHA
in CI, `git rev-parse` locally) — no manual bump needed.
The Cloudflare setup + cutover runbook is in pcbjam/deploy/site/README.md.

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
}

30
site/.dev.vars.example Normal file
View file

@ -0,0 +1,30 @@
# PCBJam marketing site — server-side secrets for the waitlist Pages Function.
#
# Copy to `.dev.vars` (gitignored) for local dev; `wrangler pages dev` loads it
# and exposes the values on the Function's `context.env`. Note this is NOT `.env`
# any more: the endpoint is a Cloudflare Pages Function, not an Astro SSR route,
# so it reads bindings rather than `astro:env/server`.
#
# In production these are set once, out of band, and are NOT deploy inputs:
# wrangler pages secret put RESEND_API_KEY --project-name pcbjam-site
# wrangler pages secret put RESEND_SEGMENT_ID --project-name pcbjam-site
# wrangler pages secret put WAITLIST_FROM_EMAIL --project-name pcbjam-site
#
# Without RESEND_API_KEY the endpoint still accepts submits (logs + no email),
# so the form UX is testable locally without keys.
# Resend API key (https://resend.com/api-keys). Server-only — never PUBLIC_.
RESEND_API_KEY=
# Resend Segment to add waitlist contacts to (https://resend.com/segments).
# (Resend renamed "Audiences" → "Segments"; this is the Segment ID.)
RESEND_SEGMENT_ID=
# Optional: confirmation sender. Domain must be verified in Resend.
# Defaults to "PCBJam <hello@pcbjam.com>" (see functions/api/waitlist.ts).
WAITLIST_FROM_EMAIL=
# Optional: comma-separated origins allowed to cross-post the waitlist form.
# Deliberately UNSET in production so it keeps the in-code default
# (https://demo.pcbjam.com) — the static demo has no backend of its own.
# WAITLIST_ALLOWED_ORIGINS=

View file

@ -1,15 +0,0 @@
# PCBJam marketing site — server-side secrets for the waitlist endpoint.
# Copy to `.env` for local dev, and set these in the Vercel project settings.
# Without RESEND_API_KEY the endpoint still accepts submits (logs + no email),
# so the form UX is testable locally without keys.
# Resend API key (https://resend.com/api-keys). Server-only — never PUBLIC_.
RESEND_API_KEY=
# Resend Segment to add waitlist contacts to (https://resend.com/segments).
# (Resend renamed "Audiences" → "Segments"; this is the Segment ID.)
RESEND_SEGMENT_ID=
# Optional: confirmation sender. Domain must be verified in Resend.
# Defaults to "PCBJam <hello@pcbjam.com>".
WAITLIST_FROM_EMAIL=

10
site/.gitignore vendored
View file

@ -1,16 +1,24 @@
# build output
dist/
.vercel/
# generated content collection types & cache
.astro/
# wrangler local state/cache + migration scratch
.wrangler/
.cf-migrate/
# dependencies
node_modules/
# environment
.env
.env.production
# wrangler local-only vars — this is where RESEND_API_KEY lives for
# `wrangler pages dev`. The root repo's `**/.dev.vars` does NOT cover a nested
# git repo, so it must be listed here.
.dev.vars
.migration-secrets.env
# macOS
.DS_Store

View file

@ -11,9 +11,9 @@ It uses **npm** (not the monorepo's pnpm) and has its own `package-lock.json`.
- Static by default: every page is prerendered to HTML and ships **zero client
JavaScript**. A visitor downloads HTML + CSS only — no React/JS bundle.
- SSR-capable: the Vercel adapter is wired in, so any individual route can be
switched to per-request server rendering without ripping anything out (see
below).
- No adapter and no server bundle: the build emits `dist/` only. The single
dynamic endpoint, `/api/waitlist`, ships as a **Cloudflare Pages Function**
from `functions/` (see below).
## Routes
@ -38,38 +38,60 @@ Requires **Node ≥ 22.12** (Astro 6 requirement).
cd site
npm install
npm run dev # http://localhost:4321
npm run build # outputs to dist/ (+ .vercel/output for the adapter)
npm run build # outputs to dist/ (static only — no adapter)
npm run preview # serve the production build locally
```
## SSR per route
## Dynamic routes
Pages are static by default. To render a specific page or endpoint on demand
(per request, as a Vercel Function), add this to its frontmatter:
Every page is static. The one server-side endpoint is a **Cloudflare Pages
Function**, not an Astro SSR route:
```astro
---
export const prerender = false;
---
```
functions/api/waitlist.ts -> POST/OPTIONS/GET /api/waitlist
```
That's the only change needed — the `@astrojs/vercel` adapter in
`astro.config.mjs` already provides the server runtime. The rest of the site
stays static.
Pages maps the `functions/` tree to routes by path, and the handlers are named
exports (`onRequestPost`, `onRequestOptions`, `onRequestGet`). Config arrives on
`context.env`, not via `astro:env/server`. `public/_routes.json` restricts
Function invocation to `/api/*`, so every page and asset stays a plain static
request.
## Deploying to Vercel
To add another endpoint, drop a new file in `functions/` — no Astro adapter and
no `prerender = false` involved.
The `@astrojs/vercel` adapter emits the Vercel Build Output API format, so **no
`vercel.json` is required**.
Locally, run the built site the way Pages will serve it (this is the only way to
exercise the Function, `astro dev` does not run `functions/`):
1. Push this repo to GitHub.
2. Vercel dashboard → **New Project** → import this repo.
3. Click **Edit** next to **Root Directory** and set it to **`site`**. This is
the standard way to deploy a project that lives in a subdirectory.
4. Vercel auto-detects the **Astro** framework preset and the package manager
from the lockfile. Leave the build/install commands at their defaults.
5. Deploy. Static pages are served from the CDN; any route with
`prerender = false` is deployed as a Vercel Function automatically.
```bash
cp .dev.vars.example .dev.vars # fill in as needed; gitignored
npm run build
npm run pages:dev # http://localhost:8788
```
Note: do **not** use `vercel.json` for URL rewrites with Astro — use Astro's
`redirects` option in `astro.config.mjs` instead.
## Deploying
`www.pcbjam.com` is a **Cloudflare Pages** project (`pcbjam-site`), deployed by
`.github/workflows/deploy-site.yml` on every push to `main` touching `site/**`
content and blog posts do not wait for a release tag.
```
push to main (site/**) -> npm ci -> npm test -> astro build
-> wrangler pages deploy -> www.pcbjam.com
```
Two things live outside the repo and are set once:
- **Secrets**`wrangler pages secret put <NAME> --project-name pcbjam-site`
for `RESEND_API_KEY`, `RESEND_SEGMENT_ID`, `WAITLIST_FROM_EMAIL`.
`WAITLIST_ALLOWED_ORIGINS` is deliberately unset; it keeps the in-code default.
- **The custom domain** — attached to the Pages project (there is no
`wrangler pages domain` subcommand). The apex `pcbjam.com` 308s to `www` via a
Cloudflare Redirect Rule.
Response headers come from `public/_headers` (copied verbatim into `dist/`), which
carries the COOP/COEP rules the embedded Gerber viewer needs. Do **not** widen
them to `/*` — the landing page must stay un-isolated so the YouTube hero embed
loads.
The full one-time setup and the cutover runbook are in `../deploy/site/README.md`.

View file

@ -1,18 +1,21 @@
// @ts-check
import { defineConfig, envField } from 'astro/config';
import vercel from '@astrojs/vercel';
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
// Static by default (every page prerenders to HTML, zero client JS).
// The Vercel adapter is wired in so any single route can opt into
// per-request SSR later with `export const prerender = false;`.
// See README.md ("SSR per route") for how.
// Fully static: every page prerenders to HTML with zero client JS, and there is
// NO adapter — the build output in dist/ is what `wrangler pages deploy` ships.
// The one dynamic route, /api/waitlist, is a Cloudflare Pages Function in
// functions/ rather than an Astro SSR route. See README.md ("Deploying").
export default defineConfig({
// Canonical origin (apex 308s to www). Without this, prerendered Astro.url
// is localhost, which leaked into canonical/OG tags on production.
site: 'https://www.pcbjam.com',
output: 'static',
adapter: vercel(),
// Deliberately NO trailingSlash setting: Astro's default emits the canonical
// with a trailing slash, which is also the form Cloudflare Pages serves at
// 200 (the bare form 308s to it). Forcing 'never' would make canonical and
// the served URL disagree.
//
// MDX lets the Gerber-viewer blog post embed the <GerberDemo /> component
// inline (markdown posts can't import components).
integrations: [mdx()],
@ -20,9 +23,9 @@ export default defineConfig({
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
// Dev-server cross-origin isolation so the embedded Gerber viewer's WASM
// threads (SharedArrayBuffer) work under `npm run dev`. Production headers are
// scoped per-route in vercel.json. require-corp (not credentialless) for the
// widest browser support incl. Safari 15.2+; safe because the site loads only
// same-origin subresources.
// scoped per-route in public/_headers. require-corp (not credentialless) for
// the widest browser support incl. Safari 15.2+; safe because the site loads
// only same-origin subresources.
vite: {
server: {
headers: {
@ -31,29 +34,8 @@ export default defineConfig({
},
},
},
// Typed, validated server secrets for the waitlist endpoint. All optional so
// the build never requires them and the endpoint degrades gracefully when a
// key is absent (see src/pages/api/waitlist.ts). The @astrojs/vercel adapter
// reads these from process.env at runtime — never inlined into the bundle.
env: {
schema: {
RESEND_API_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
RESEND_SEGMENT_ID: envField.string({ context: 'server', access: 'secret', optional: true }),
WAITLIST_FROM_EMAIL: envField.string({
context: 'server',
access: 'secret',
optional: true,
default: 'PCBJam <hello@pcbjam.com>',
}),
// Comma-separated origins allowed to cross-post the waitlist form (e.g. the
// static demo at demo.pcbjam.com, which has no backend of its own). Not a
// secret — just config. Same-origin submits never hit this.
WAITLIST_ALLOWED_ORIGINS: envField.string({
context: 'server',
access: 'public',
optional: true,
default: 'https://demo.pcbjam.com',
}),
},
},
// No `env.schema`: the waitlist secrets are no longer read through
// `astro:env/server`. They reach the Pages Function as bindings on its
// `context.env` (`wrangler pages secret put`), and the two former schema
// defaults now live in functions/api/waitlist.ts.
});

View file

@ -1,19 +1,33 @@
import type { APIRoute } from 'astro';
import { Resend } from 'resend';
// Typed server secrets (schema in astro.config.mjs). Optional, so RESEND_API_KEY
// and RESEND_SEGMENT_ID are `string | undefined`; WAITLIST_FROM_EMAIL has a
// schema default so it's always a `string`. The Vercel adapter reads these from
// process.env at runtime — never inlined.
import {
RESEND_API_KEY,
RESEND_SEGMENT_ID,
WAITLIST_FROM_EMAIL,
WAITLIST_ALLOWED_ORIGINS,
} from 'astro:env/server';
// Opt this single route into on-demand (serverless) rendering. The rest of the
// site stays static; Vercel emits exactly one function for /api/waitlist.
export const prerender = false;
/**
* Waitlist endpoint a Cloudflare Pages Function.
*
* The rest of the site is fully static, so this is the only server-side code we
* ship. It lives in functions/ rather than src/pages/ because the Astro build
* has no adapter; Pages routes /api/waitlist here (see public/_routes.json,
* which keeps every other path a plain static asset request).
*
* Secrets arrive as bindings on `context.env`:
* wrangler pages secret put RESEND_API_KEY --project-name pcbjam-site
* Locally they come from site/.dev.vars (gitignored). Without RESEND_API_KEY the
* endpoint still accepts submits (logs + no email), so the form UX is testable
* without keys.
*/
interface Env {
RESEND_API_KEY?: string;
RESEND_SEGMENT_ID?: string;
WAITLIST_FROM_EMAIL?: string;
WAITLIST_ALLOWED_ORIGINS?: string;
}
/**
* These two used to be `default:` values in astro.config.mjs's env schema. That
* schema is gone with the adapter, so they live here WAITLIST_ALLOWED_ORIGINS
* especially, since `undefined.split(',')` would throw on every preflight.
*/
const DEFAULT_FROM_EMAIL = 'PCBJam <hello@pcbjam.com>';
const DEFAULT_ALLOWED_ORIGINS = 'https://demo.pcbjam.com';
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
@ -30,9 +44,9 @@ function maskEmail(email: string): string {
/**
* Best-effort per-key rate limit an in-process sliding window that bounds
* bursts from one warm instance. NOT a complete defense on serverless (fresh
* instances don't share it), so production should ALSO front this with a shared
* store (Vercel KV / Upstash) or a CAPTCHA. Kept dependency-free so it runs.
* bursts from one warm isolate. NOT a complete defense (fresh isolates don't
* share it), so production should ALSO front this with a shared store or a
* CAPTCHA. Kept dependency-free so it runs.
*/
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const RATE_MAX_PER_IP = 5;
@ -50,8 +64,14 @@ function rateLimited(key: string, max: number, now: number): boolean {
return false;
}
/** Client IP from the standard proxy headers (Vercel sets x-forwarded-for). */
/**
* Client IP. Cloudflare sets CF-Connecting-IP and it cannot be spoofed by the
* client; x-forwarded-for is kept as a fallback for `wrangler pages dev` and the
* unit tests.
*/
function clientIp(request: Request): string {
const cf = request.headers.get('cf-connecting-ip');
if (cf) return cf;
const xff = request.headers.get('x-forwarded-for');
return (xff ? xff.split(',')[0] : '').trim() || 'unknown';
}
@ -61,14 +81,21 @@ function clientIp(request: Request): string {
* static demo (demo.pcbjam.com, no backend of its own) cross-post the form. A
* same-origin submit sends no Origin and gets no CORS headers (it doesn't need
* them). The JSON content-type triggers a preflight, hence the OPTIONS handler.
*
* Note this endpoint must be reachable on www WITHOUT a redirect: a CORS
* preflight cannot follow one, so the demo posts to the canonical www host.
*/
function corsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('origin');
if (!origin) return {};
const allowed = WAITLIST_ALLOWED_ORIGINS.split(',')
function allowedOrigins(env: Env): string[] {
return (env.WAITLIST_ALLOWED_ORIGINS ?? DEFAULT_ALLOWED_ORIGINS)
.split(',')
.map((o) => o.trim())
.filter(Boolean);
if (!allowed.includes(origin)) return {};
}
function corsHeaders(request: Request, env: Env): Record<string, string> {
const origin = request.headers.get('origin');
if (!origin) return {};
if (!allowedOrigins(env).includes(origin)) return {};
return {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'POST, OPTIONS',
@ -97,18 +124,44 @@ function redirect(status: 'ok' | 'error') {
});
}
export const OPTIONS: APIRoute = ({ request }) =>
new Response(null, { status: 204, headers: corsHeaders(request) });
export const onRequestOptions: PagesFunction<Env> = ({ request, env }) =>
new Response(null, { status: 204, headers: corsHeaders(request, env) });
export const POST: APIRoute = async ({ request }) => {
const cors = corsHeaders(request);
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
const cors = corsHeaders(request, env);
const ct = request.headers.get('content-type') ?? '';
const wantsJson = ct.includes('application/json');
/**
* Cross-site form-POST guard.
*
* Vercel's edge did this for free ("Cross-site POST form submissions are
* forbidden", HTTP 403) and Cloudflare Pages does not, so it is reproduced here
* rather than silently lost in the migration. A form-encoded POST carrying a
* foreign Origin is the classic CSRF shape: unlike fetch(), a cross-site <form>
* submit needs no CORS permission to be *sent*, so the allowlist above cannot
* stop it. Without this, any page could sign arbitrary addresses up and have us
* email them.
*
* JSON posts are deliberately exempt that is the demo's cross-origin path,
* and it IS governed by the CORS allowlist. Browsers that omit Origin on a
* same-origin form submit are unaffected: the check only fires when Origin is
* present and foreign.
*/
if (!wantsJson) {
const origin = request.headers.get('origin');
if (origin && origin !== new URL(request.url).origin && !allowedOrigins(env).includes(origin)) {
return new Response('Cross-site POST form submissions are forbidden', {
status: 403,
headers: { 'content-type': 'text/plain;charset=UTF-8' },
});
}
}
let data: Record<string, unknown> = {};
try {
data = wantsJson
? await request.json()
? ((await request.json()) as Record<string, unknown>)
: Object.fromEntries(await request.formData());
} catch {
return wantsJson ? json(400, { ok: false, error: 'bad_request' }, cors) : redirect('error');
@ -118,7 +171,10 @@ export const POST: APIRoute = async ({ request }) => {
const honeypot = String(data.company_url ?? ''); // hidden field — must stay empty
const source = String(data.source ?? 'unknown');
// Bot caught by honeypot: silently "succeed" so we don't tip them off.
// Bot caught by honeypot: silently "succeed" so we don't tip them off. This
// branch deliberately precedes validation, the rate limiter and any Resend
// call, which also makes it the one probe that can exercise the full request
// path against production without side effects.
if (honeypot) return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
if (!EMAIL_RE.test(email) || email.length > 254) {
@ -139,14 +195,14 @@ export const POST: APIRoute = async ({ request }) => {
}
// No key configured (e.g. local dev without secrets): accept + log, don't 500.
if (!RESEND_API_KEY) {
if (!env.RESEND_API_KEY) {
console.warn(
`[waitlist] RESEND_API_KEY not set — skipping send. email=${maskEmail(email)} source=${source}`,
);
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
}
const resend = new Resend(RESEND_API_KEY);
const resend = new Resend(env.RESEND_API_KEY);
try {
// Add to the segment (the modern name for an "audience"). The SDK returns
@ -154,11 +210,11 @@ export const POST: APIRoute = async ({ request }) => {
// no stable error code (it surfaces as a validation_error), so we treat the
// contact step as best-effort: log any error but never fail the request on
// it — the user-facing promise is the confirmation email below.
if (RESEND_SEGMENT_ID) {
if (env.RESEND_SEGMENT_ID) {
const { error: contactError } = await resend.contacts.create({
email,
unsubscribed: false,
segments: [{ id: RESEND_SEGMENT_ID }],
segments: [{ id: env.RESEND_SEGMENT_ID }],
});
if (contactError) {
console.error('[waitlist] contacts.create failed (non-fatal)', contactError);
@ -166,7 +222,7 @@ export const POST: APIRoute = async ({ request }) => {
}
const { error: sendError } = await resend.emails.send({
from: WAITLIST_FROM_EMAIL,
from: env.WAITLIST_FROM_EMAIL ?? DEFAULT_FROM_EMAIL,
to: email,
subject: "You're on the PCBJam waitlist",
text: [
@ -199,4 +255,5 @@ export const POST: APIRoute = async ({ request }) => {
};
// A bare GET (e.g. someone visiting the URL) shouldn't 500.
export const GET: APIRoute = () => json(405, { ok: false, error: 'method_not_allowed' });
export const onRequestGet: PagesFunction<Env> = () =>
json(405, { ok: false, error: 'method_not_allowed' });

4
site/functions/env.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
// Workers/Pages ambient types. `PagesFunction<Env>` and `EventContext` are
// globals from this package — without the reference they don't resolve, because
// tsconfig extends astro/tsconfigs/strict, which only wires Astro/Vite types.
/// <reference types="@cloudflare/workers-types" />

752
site/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,17 +12,18 @@
"preview": "astro preview",
"check": "astro check",
"astro": "astro",
"test": "vitest run"
"test": "vitest run",
"pages:dev": "npx --yes wrangler@4 pages dev"
},
"dependencies": {
"@astrojs/mdx": "^6.0.3",
"@astrojs/vercel": "^10.0.8",
"@fontsource/oswald": "^5.2.8",
"astro": "^6.4.4",
"resend": "^6.12.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.9",
"@cloudflare/workers-types": "^4.20260610.1",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^26.1.0",
"happy-dom": "^15.11.6",

35
site/public/_headers Normal file
View file

@ -0,0 +1,35 @@
# Cloudflare Pages response headers for www.pcbjam.com.
# Copied verbatim from public/ into dist/ by the Astro build. Replaces the
# `headers` block of the old vercel.json.
#
# Cross-origin isolation is required for the embedded KiCad Gerber viewer
# (SharedArrayBuffer + pthreads). The big WASM blobs it pulls are cross-origin on
# cdn.pcbjam.com and satisfy COEP via their own CORP/ACAO headers, set by a
# Cloudflare Transform Rule on that hostname (see deploy/demo/README.md).
#
# DO NOT widen these to `/*`. The landing page deliberately is NOT isolated: a
# require-corp document cannot load the no-COEP YouTube hero iframe. See the
# comment in src/sections/GerberDemoSection.astro.
#
# Both URL forms of the blog post are listed on purpose. Pages serves the
# trailing-slash form at 200 and 308s the bare form to it, so an exact-match rule
# alone would attach these headers to the redirect and not to the document —
# which is precisely the bug this file inherited from vercel.json.
# Content-hashed build output: safe to cache forever. This used to come from the
# Vercel adapter's generated route config; with no adapter, nothing sets it unless
# we do.
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/blog/porting-kicad-graphics-to-webgl-in-2026
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
/blog/porting-kicad-graphics-to-webgl-in-2026/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
/gerber-demo/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

5
site/public/_routes.json Normal file
View file

@ -0,0 +1,5 @@
{
"version": 1,
"include": ["/api/*"],
"exclude": []
}

View file

@ -41,7 +41,7 @@ mirrors the standalone editor's `web/standalone/src/wasm/boot.ts`.
| `src/sections/GerberDemoSection.astro` | The landing-page showcase: a poster + launch button that opens `/gerber-demo/` in a new tab (the landing itself is not cross-origin isolated). |
| `src/components/GerberDemo.astro` | The blog embed: lazy click-to-load iframe, cross-origin-isolation reload guard, feature-detect + poster fallback. |
| `astro.config.mjs` + `src/middleware.ts` | Dev cross-origin-isolation headers (COOP/COEP `require-corp`). |
| `vercel.json` | Prod COOP/COEP, scoped to the blog post + `/gerber-demo/` routes. |
| `public/_headers` | Prod COOP/COEP on Cloudflare Pages, scoped to the blog post + `/gerber-demo/` routes (both URL forms of the post). |
## Dev overrides

View file

@ -9,11 +9,14 @@ const GITHUB_URL = 'https://github.com/emergence-engineering';
// Build provenance: the pcbjam commit this site was built from — it pins the
// KiCad + wxWidgets submodule revisions implicitly and is our GPLv3
// corresponding-source pointer (see /licenses). Resolved at build time:
// Vercel sets VERCEL_GIT_COMMIT_SHA; local builds fall back to git.
// corresponding-source pointer (see /licenses), so it must not silently degrade
// to 'main'. Resolved at build time, in order: Cloudflare Pages CI
// (CF_PAGES_COMMIT_SHA), GitHub Actions (GITHUB_SHA), then git. A direct-upload
// `wrangler pages deploy` sets neither variable, but deploy-site.yml runs in
// Actions on a real checkout, so the git fallback is correct there too.
function resolveBuildSha(): string {
const vercelSha = process.env.VERCEL_GIT_COMMIT_SHA;
if (vercelSha) return vercelSha.slice(0, 7);
const ciSha = process.env.CF_PAGES_COMMIT_SHA || process.env.GITHUB_SHA;
if (ciSha) return ciSha.slice(0, 7);
try {
return execSync('git rev-parse --short=7 HEAD', { encoding: 'utf8' }).trim();
} catch {

View file

@ -9,7 +9,7 @@
* The live viewer needs cross-origin isolation (SharedArrayBuffer + pthreads)
* and WebGL2. We feature-detect and degrade gracefully: capable browsers get the
* live viewer; everything else gets a static poster + a short note. COOP/COEP are
* applied to this route (dev: astro.config.mjs; prod: vercel.json); because
* applied to this route (dev: astro.config.mjs; prod: public/_headers); because
* Astro's <ClientRouter/> can deliver this page via a soft SPA swap (which is
* NOT cross-origin isolated), the client script does one guarded reload so the
* route headers take effect.
@ -148,7 +148,7 @@ const caption =
}
// Reloaded moments ago and STILL not isolated (stale cache, missing headers,
// a preview without vercel.json…). Don't dead-end and don't blame the
// a preview without _headers…). Don't dead-end and don't blame the
// browser: the standalone viewer page is isolated on its own, so let the
// button open it in a new tab — capable browsers get the full viewer there.
if (launchEl) {

View file

@ -1,7 +1,7 @@
---
title: Cookie Policy
description: How PCBJam uses cookies and similar technologies, and the choices you have.
updated: 2026-07-15
updated: 2026-07-27
---
This Cookie Policy explains how **PCBJam** ("PCBJam", "we", "us", "our") uses cookies and similar technologies — such as browser local storage, IndexedDB and cache storage — when you visit **pcbjam.com** or use the PCBJam application (the "Service"), and the choices you have.
@ -61,7 +61,7 @@ The PCBJam application is rolling out in stages. We've split the tables so you c
**The PCBJam website does not set any cookies that require your consent.** Our usage measurement is cookieless (see Section 5), and joining the waitlist is handled by a server request — it does not place a cookie on your device.
If our hosting provider (Vercel) sets a strictly-necessary cookie for security or load-balancing in some circumstances, that cookie is essential to delivering the page you requested and carries no tracking function.
If our hosting provider (Cloudflare) sets a strictly-necessary cookie for security or load-balancing in some circumstances, that cookie is essential to delivering the page you requested and carries no tracking function.
### 4b. The PCBJam application (as it rolls out)
@ -100,7 +100,7 @@ We do **not** use Google Analytics, advertising pixels, or any cross-site tracki
|---|---|---|---|
| **Paddle** (Paddle.com Market Limited and affiliates) | Merchant of Record / payments | Checkout, security and fraud-prevention cookies; their own non-essential cookies (with consent where required, via Paddle's own controls). Independent controller. | [Paddle Privacy Policy](https://www.paddle.com/legal/privacy); Paddle's cookie controls appear within its checkout. |
| **Plausible** (Plausible Insights OÜ) | Cookieless analytics | Nothing — no cookies and no device storage (see Section 5). | [Plausible Data Policy](https://plausible.io/data-policy) · [Plausible Privacy](https://plausible.io/privacy) |
| **Vercel** (Vercel Inc.) | Hosting | At most strictly-necessary hosting cookies. | [Vercel Privacy](https://vercel.com/legal/privacy-policy) |
| **Cloudflare** (Cloudflare, Inc.) | Hosting, CDN and network security | At most strictly-necessary hosting/security cookies. | [Cloudflare Privacy](https://www.cloudflare.com/privacypolicy/) |
We are not responsible for the privacy practices of these third parties; please review their notices. We update this list as our integrations change.

View file

@ -1,7 +1,7 @@
---
title: Privacy Policy
description: How PCBJam handles your personal data.
updated: 2026-07-15
updated: 2026-07-27
---
## 1. A quick summary
@ -142,11 +142,10 @@ We do **not** sell your personal data. We share it only with the following categ
| Recipient | What they do | Their role | More info |
|---|---|---|---|
| **Vercel** | Hosting/infrastructure | **Processor** | [Vercel DPA](https://vercel.com/legal/dpa) · [Privacy](https://vercel.com/legal/privacy-policy) |
| **Plausible** | Cookieless web analytics | **Processor** | [Plausible DPA](https://plausible.io/dpa) · [Data policy](https://plausible.io/data-policy) |
| **Paddle** | Payments / Merchant of Record | **Independent controller** (for payment data) | [Paddle Privacy](https://www.paddle.com/legal/privacy) |
| **Resend** | Sending transactional and marketing email | **Processor** | [Resend DPA](https://resend.com/legal/dpa) · [Subprocessors](https://resend.com/legal/subprocessors) |
| **Cloudflare** | Storing your project files (R2 object storage), hosting application/account data, and network delivery & security (CDN, DNS, WAF) | **Processor** | [Cloudflare DPA](https://www.cloudflare.com/cloudflare-customer-dpa/) · [GDPR hub](https://www.cloudflare.com/trust-hub/gdpr/) |
| **Cloudflare** | Hosting this website, storing your project files (R2 object storage), hosting application/account data, and network delivery & security (CDN, DNS, WAF) | **Processor** | [Cloudflare DPA](https://www.cloudflare.com/cloudflare-customer-dpa/) · [GDPR hub](https://www.cloudflare.com/trust-hub/gdpr/) |
| **Google (Google Workspace)** | Our business email and support correspondence | **Processor** | [Google Cloud DPA](https://cloud.google.com/terms/data-processing-addendum) |
| **Professional advisers & authorities** | Lawyers, accountants, auditors; courts, regulators, and law-enforcement where legally required | Controller / as required | — |
| **A successor entity** | If we are involved in a merger, acquisition, financing, or sale of assets, your data may transfer to the successor under this policy | As required | — |
@ -168,7 +167,7 @@ PCBJam lets you **publish projects and share them** with others (for example via
## 11. International data transfers
We are based in the EU, but some of our providers (for example, Vercel, Cloudflare, Paddle, and Resend) may process data **outside the European Economic Area**, including in the United States. Where personal data is transferred outside the EEA/UK, we rely on an appropriate safeguard under Chapter V GDPR, such as:
We are based in the EU, but some of our providers (for example, Cloudflare, Paddle, and Resend) may process data **outside the European Economic Area**, including in the United States. Where personal data is transferred outside the EEA/UK, we rely on an appropriate safeguard under Chapter V GDPR, such as:
- an **adequacy decision** by the European Commission (including, where applicable, the **EUUS Data Privacy Framework** for certified US providers); or
- the European Commission's **Standard Contractual Clauses** (and the UK International Data Transfer Addendum), together with additional safeguards where needed.

View file

@ -68,7 +68,7 @@ const ogImageAbs = new URL(ogImage, Astro.site ?? Astro.url).href;
pcbjam properties. Prod builds only — `astro dev` stays untracked, and
Plausible ignores localhost regardless. Auto-tracks pageviews across
ClientRouter navigations (History API). crossorigin="anonymous" so the
script still loads on the COEP require-corp routes (see vercel.json):
script still loads on the COEP require-corp routes (see public/_headers):
cross-origin subresources there must be fetched with CORS. */}
{
import.meta.env.PROD && (

View file

@ -9,7 +9,7 @@ import { defineMiddleware } from 'astro:middleware';
* cross-origin isolated, and the embedded Gerber viewer's SharedArrayBuffer
* would be unavailable. Set them here for `npm run dev`.
*
* Production is static (output: 'static'); these headers come from vercel.json
* Production is static (output: 'static'); these headers come from public/_headers
* scoped to the post + /gerber-demo routes, so we no-op outside dev to keep the
* prerender/build output untouched.
*/

46
site/src/pages/404.astro Normal file
View file

@ -0,0 +1,46 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
// Prerendered to dist/404.html, which Cloudflare Pages serves with a real 404
// status for any unmatched path. This page is NOT optional: 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. (On Vercel the SSR function rendered Astro's built-in 404 instead.)
---
<BaseLayout title="Page not found" description="That page doesn't exist.">
<section class="notfound">
<p class="code">404</p>
<h1>That page doesn't exist.</h1>
<p class="muted">
It may have moved, or the link may be wrong. The blog index and the
homepage are good places to pick things back up.
</p>
<p class="links">
<a href="/">Homepage</a>
<a href="/blog">Blog</a>
</p>
</section>
</BaseLayout>
<style>
.notfound {
padding: 5rem 0 6rem;
max-width: 34rem;
}
.code {
font-family: var(--font-display, inherit);
font-size: 3rem;
line-height: 1;
margin: 0 0 0.5rem;
opacity: 0.35;
}
.notfound h1 {
margin: 0 0 0.75rem;
}
.links {
display: flex;
gap: 1.25rem;
margin-top: 2rem;
}
</style>

View file

@ -7,7 +7,7 @@
* deliberately do NOT apply to the landing page: that would block the YouTube
* hero embed (a no-COEP cross-origin iframe can't load in an isolated document).
* So Launch opens the standalone viewer (/gerber-demo/) in a NEW TAB: that page
* IS cross-origin isolated (vercel.json) and boots the board immediately.
* IS cross-origin isolated (public/_headers) and boots the board immediately.
*/
import SectionBand from '../components/SectionBand.astro';

View file

@ -1,19 +1,14 @@
import { describe, expect, it, vi } from "vitest";
/**
* Waitlist route hardening: per-key rate limiting, and no raw address in logs on
* the no-key path.
* Waitlist Function hardening: per-key rate limiting, and no raw address in logs
* on the no-key path.
*
* The route imports typed Astro env + the Resend SDK; both are stubbed so the
* handler runs in plain node. RESEND_API_KEY is left undefined so the no-key
* The endpoint is a Cloudflare Pages Function, so its config arrives as an `env`
* object on the request context no `astro:env/server` stub needed any more.
* Only the Resend SDK is mocked. RESEND_API_KEY is left undefined so the no-key
* branch (the one that logs) is exercised.
*/
vi.mock("astro:env/server", () => ({
RESEND_API_KEY: undefined,
RESEND_SEGMENT_ID: undefined,
WAITLIST_FROM_EMAIL: "hello@pcbjam.com",
WAITLIST_ALLOWED_ORIGINS: "",
}));
vi.mock("resend", () => ({
Resend: class {
emails = { send: async () => ({}) };
@ -21,19 +16,37 @@ vi.mock("resend", () => ({
},
}));
const { POST } = await import("../src/pages/api/waitlist.ts");
const { onRequestPost } = await import("../functions/api/waitlist.ts");
/**
* WAITLIST_ALLOWED_ORIGINS must be present (as "") rather than omitted: the
* Function falls back to its default allowlist when the key is absent, and these
* tests should not depend on that default.
*/
const env = {
RESEND_API_KEY: undefined,
RESEND_SEGMENT_ID: undefined,
WAITLIST_FROM_EMAIL: "hello@pcbjam.com",
WAITLIST_ALLOWED_ORIGINS: "",
};
function post(email: string, ip: string): Promise<Response> {
const request = new Request("https://www.pcbjam.com/api/waitlist", {
method: "POST",
headers: { "content-type": "application/json", "x-forwarded-for": ip },
// cf-connecting-ip, not x-forwarded-for: the Function prefers the Cloudflare
// header. Using the wrong one here would silently key every request on
// "unknown", collapsing both tests into one rate-limit bucket while the
// assertions below still passed.
headers: { "content-type": "application/json", "cf-connecting-ip": ip },
body: JSON.stringify({ email }),
});
// Astro passes a full context; the handler only reads `request`.
return POST({ request } as unknown as Parameters<typeof POST>[0]) as Promise<Response>;
// Pages passes a full EventContext; the handler only reads `request` and `env`.
return onRequestPost({ request, env } as unknown as Parameters<
typeof onRequestPost
>[0]) as Promise<Response>;
}
describe("waitlist route hardening", () => {
describe("waitlist function hardening", () => {
it("rate-limits a burst from one IP (429 after the per-IP cap)", async () => {
const ip = "203.0.113.9";
const statuses: number[] = [];
@ -50,4 +63,56 @@ describe("waitlist route hardening", () => {
expect(logged).toContain("@example.com"); // domain kept for debugging
warn.mockRestore();
});
it("refuses a cross-site form POST (the guard Vercel's edge used to provide)", async () => {
// A cross-site <form> submit needs no CORS permission to be SENT, so the
// allowlist cannot stop it. Vercel's edge returned 403 for this; Cloudflare
// Pages does not, so the Function has to.
const request = new Request("https://www.pcbjam.com/api/waitlist", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
"cf-connecting-ip": "198.51.100.9",
origin: "https://evil.example",
},
body: "email=victim%40example.com",
});
const res = (await onRequestPost({ request, env } as unknown as Parameters<
typeof onRequestPost
>[0])) as Response;
expect(res.status).toBe(403);
});
it("allows a same-origin form POST (the no-JS path must keep working)", async () => {
const request = new Request("https://www.pcbjam.com/api/waitlist", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
"cf-connecting-ip": "198.51.100.10",
origin: "https://www.pcbjam.com",
},
body: "email=nojs%40example.com",
});
const res = (await onRequestPost({ request, env } as unknown as Parameters<
typeof onRequestPost
>[0])) as Response;
expect(res.status).toBe(303);
expect(res.headers.get("location")).toBe("/?waitlist=ok#waitlist");
});
it("sends no CORS headers for an origin outside the allowlist", async () => {
const request = new Request("https://www.pcbjam.com/api/waitlist", {
method: "POST",
headers: {
"content-type": "application/json",
"cf-connecting-ip": "198.51.100.8",
origin: "https://not-allowed.example",
},
body: JSON.stringify({ email: "cors@example.com" }),
});
const res = (await onRequestPost({ request, env } as unknown as Parameters<
typeof onRequestPost
>[0])) as Response;
expect(res.headers.get("access-control-allow-origin")).toBeNull();
});
});

View file

@ -1,5 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
"exclude": ["dist", ".wrangler", ".cf-migrate"]
}

View file

@ -1,19 +0,0 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"headers": [
{
"source": "/blog/porting-kicad-graphics-to-webgl-in-2026",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
]
},
{
"source": "/gerber-demo/(.*)",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
]
}
]
}

21
site/wrangler.toml Normal file
View file

@ -0,0 +1,21 @@
# Cloudflare Pages config for the marketing site (project `pcbjam-site`).
#
# This exists to keep the Function's RUNTIME CONTRACT in version control rather
# than living only in whatever flags the Pages project happened to be created
# with — the same reasoning as apps/server/wrangler.jsonc declaring its custom
# domain in code.
#
# nodejs_compat is for the `resend` SDK used by functions/api/waitlist.ts.
#
# Secrets are NOT here — they are set once, out of band:
# wrangler pages secret put RESEND_API_KEY --project-name pcbjam-site
# and locally come from .dev.vars (gitignored). WAITLIST_ALLOWED_ORIGINS is
# deliberately unset so it keeps the default in functions/api/waitlist.ts.
#
# The custom domain (www.pcbjam.com) is attached out of band too — there is no
# `wrangler pages domain` subcommand; see deploy/site/README.md.
name = "pcbjam-site"
pages_build_output_dir = "./dist"
compatibility_date = "2026-06-01"
compatibility_flags = ["nodejs_compat"]