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
2026-07-27 13:34:12 +02:00
#!/usr/bin/env bash
chore(deploy/site): retire the migration scaffolding, keep the health check
The Vercel -> Cloudflare Pages move is done and the Vercel project is
deleted, so the one-shot scripts have no remaining purpose. Nothing in CI
ever called them — deploy-site.yml runs npm ci / test / build / pages
deploy inline — so this removes 10 files and orphans nothing.
Deleted: 00-baseline (refused to run without x-vercel-id, so permanently
unrunnable), 01-preflight (proved Vercel state and API-token scopes),
07-dns-cutover (the phased cutover; in the end the records were attached
through the dashboard, and the rules/apex phases went unused once we chose
APEX_MODE=serve), 09-detach-vercel (its target project is gone), plus
03-ensure-project, 04-set-secrets, 05-deploy, 06-verify-deploy,
02-verify-local and 99-rollback, all either spent or duplicating CI. Their
lib/cf-api.sh went with them: the survivors use wrangler, so the whole
remaining path needs only `wrangler login` and no zone scopes.
What is kept is the part with ongoing value: lib/parity.sh, the assertion
set that caught five real defects during the migration — the live COOP/COEP
bug on the post's canonical URL, the soft-404 Pages would have introduced,
the cross-site form-POST guard Vercel had been providing for free, the
missing immutable header, and HSTS max-age=0. "Does the page return 200"
catches none of those.
08-verify-prod.sh becomes verify.sh, since the numbered sequence it
belonged to no longer exists. It drops the stamp machinery, the dry-run
plumbing and the Vercel-fallback messaging (there is no fallback now:
recovery is promoting a previous Pages deployment), and gains --skip-dns /
--skip-domains so it can be pointed at a single deployment via PROD_BASE
before promoting it.
The README is rewritten around the four invariants that fail SILENTLY —
never widen _headers to /*, keep both URL forms of the Gerber post, never
delete 404.astro, keep the cross-site form-POST guard — each with the
reason, since the reason is the only thing that stops someone simplifying
them back out.
Verified after: 21 probes, 20 pass, 1 warn (HSTS max-age is 6 months vs
Vercel's 2 years — on, just shorter), 0 fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 15:08:56 +02:00
# The one HTTP parity sweep. Point it at any base URL and it asks the same
# question, so a regression has nowhere to hide:
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
2026-07-27 13:34:12 +02:00
#
chore(deploy/site): retire the migration scaffolding, keep the health check
The Vercel -> Cloudflare Pages move is done and the Vercel project is
deleted, so the one-shot scripts have no remaining purpose. Nothing in CI
ever called them — deploy-site.yml runs npm ci / test / build / pages
deploy inline — so this removes 10 files and orphans nothing.
Deleted: 00-baseline (refused to run without x-vercel-id, so permanently
unrunnable), 01-preflight (proved Vercel state and API-token scopes),
07-dns-cutover (the phased cutover; in the end the records were attached
through the dashboard, and the rules/apex phases went unused once we chose
APEX_MODE=serve), 09-detach-vercel (its target project is gone), plus
03-ensure-project, 04-set-secrets, 05-deploy, 06-verify-deploy,
02-verify-local and 99-rollback, all either spent or duplicating CI. Their
lib/cf-api.sh went with them: the survivors use wrangler, so the whole
remaining path needs only `wrangler login` and no zone scopes.
What is kept is the part with ongoing value: lib/parity.sh, the assertion
set that caught five real defects during the migration — the live COOP/COEP
bug on the post's canonical URL, the soft-404 Pages would have introduced,
the cross-site form-POST guard Vercel had been providing for free, the
missing immutable header, and HSTS max-age=0. "Does the page return 200"
catches none of those.
08-verify-prod.sh becomes verify.sh, since the numbered sequence it
belonged to no longer exists. It drops the stamp machinery, the dry-run
plumbing and the Vercel-fallback messaging (there is no fallback now:
recovery is promoting a previous Pages deployment), and gains --skip-dns /
--skip-domains so it can be pointed at a single deployment via PROD_BASE
before promoting it.
The README is rewritten around the four invariants that fail SILENTLY —
never widen _headers to /*, keep both URL forms of the Gerber post, never
delete 404.astro, keep the cross-site form-POST guard — each with the
reason, since the reason is the only thing that stops someone simplifying
them back out.
Verified after: 21 probes, 20 pass, 1 warn (HSTS max-age is 6 months vs
Vercel's 2 years — on, just shorter), 0 fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 15:08:56 +02:00
# https://www.pcbjam.com production
# https://<hash>.pcbjam-site.pages.dev a specific deployment, before promoting
# http://127.0.0.1:8788 `wrangler pages dev` locally
#
# It was written for the Vercel -> Cloudflare Pages migration and several
# assertions exist because that migration found real defects; the comments say
# which, since the reason is the only thing stopping someone "simplifying" them
# back out.
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
2026-07-27 13:34:12 +02:00
#
# 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
2026-07-27 14:50:34 +02:00
# HSTS is a deliberate, independent choice — one zone toggle, unrelated to
# whether the migration worked. Absent is a WARN, not a FAIL: failing here
# would tell an operator to roll back a healthy cutover. Set EXPECT_HSTS=1
# once you have enabled it, and it becomes a hard assertion.
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
2026-07-27 13:34:12 +02:00
_hsts = " $( _hdr " $( _headers " $_base / " ) " strict-transport-security) "
case " $_hsts " in
2026-07-27 14:54:13 +02:00
*max-age= 0*)
# Distinct from — and worse than — absent. max-age=0 actively instructs
# browsers to DISCARD any HSTS policy they hold for this host, so it
# revokes the 2-year policy Vercel was setting for every returning
# visitor. Always a hard failure, regardless of EXPECT_HSTS: nobody
# deliberately wants a header whose only effect is to switch protection off.
fail hsts - "max-age=0 — HSTS is OFF and this actively clears the policy browsers already hold. Set Max Age to 12 months or 2 years in SSL/TLS -> Edge Certificates." ; ;
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
2026-07-27 13:34:12 +02:00
*max-age= 63072000*) pass hsts - " $_hsts " ; ;
2026-07-27 14:50:34 +02:00
"" ) if [ " ${ EXPECT_HSTS :- 0 } " = 1 ] ; then
fail hsts - "absent, but EXPECT_HSTS=1"
else
soft hsts - "absent (Vercel sent max-age=63072000) — enable it in SSL/TLS -> Edge Certificates, then set EXPECT_HSTS=1"
fi ; ;
2026-07-27 14:54:13 +02:00
*) soft hsts - " $_hsts (differs from the Vercel baseline max-age=63072000, but non-zero so protection is on) " ; ;
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
2026-07-27 13:34:12 +02:00
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
}
feat(deploy/site): serve the apex from the same Pages project, no redirect rule
Vercel was doing the apex->www 308 itself (its "redirect to www" project
setting), so nothing about Cloudflare requires a redirect — the behaviour
just disappears with Vercel. Rather than rebuild it with a zone Redirect
Rule plus a proxied placeholder record, attach pcbjam.com as a SECOND
custom domain on pcbjam-site. Both hosts serve the site and the pages
already emit canonical=www, which is what consolidates them for search.
That drops the riskiest artefact in the migration. Redirect Rules are
zone-scoped and run BEFORE Workers/Pages routing, so a `contains` match
instead of `eq` would 308 app./editor./demo./api. to www — breaking the
product API, not just a marketing page. The sibling hosts are also the
reason this was worth avoiding rather than merely guarding.
APEX_MODE (lib/common.sh) selects the topology, defaulting to `serve`.
08-verify-prod.sh now dispatches through assert_apex: in serve mode it
requires the apex to answer 200 with no hop, to not be a stale Vercel
response, to declare canonical=www, and to expose /api/waitlist. The
`redirect` mode and 07's rules/apex phases are kept for the alternative.
08 also checks the attached domains via wrangler rather than the REST API,
so the whole serve-mode path needs only `wrangler login` — no zone scopes
at all.
Comments that explained themselves via the old redirect are corrected:
astro.config.mjs, web/standalone/src/lib/config.ts and
scripts/deploy/build-demo.mjs. The demo keeps posting to www — not because
the apex redirects, but because a CORS preflight cannot follow one, so
aiming at a host that might ever redirect is a latent breakage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 14:34:16 +02:00
# Dispatch on the chosen apex topology (see APEX_MODE in lib/common.sh).
assert_apex( ) {
case " ${ APEX_MODE :- serve } " in
serve) assert_apex_serves " ${ 1 :- $APEX_BASE } " ; ;
redirect) assert_apex_redirect " ${ 1 :- $APEX_BASE } " ; ;
*) die " APEX_MODE must be 'serve' or 'redirect' (got ' ${ APEX_MODE } ') " ; ;
esac
}
# APEX_MODE=serve: the apex is its own Pages custom domain. It must answer 200
# directly (no hop), and it must still declare www as canonical — that tag is the
# only thing consolidating the two hostnames for search, since we are deliberately
# not redirecting.
assert_apex_serves( ) {
_apex = " ${ 1 :- $APEX_BASE } "
section " apex serves directly: $_apex (APEX_MODE=serve) "
_rc = 0
_t = " $( _trace " $_apex / " ) "
_st = " $( printf '%s' " $_t " | cut -f2) " ; _hops = " $( printf '%s' " $_t " | cut -f3) "
if [ " $_st " = "200" ] ; then
echo " PASS $_apex / -> 200 (hops $_hops ) "
else
echo " FAIL $_apex / -> $_st (expected 200; is the apex attached as a custom domain?) " ; _rc = 1
fi
_h = " $( _headers " $_apex / " ) "
if [ -n " $( _hdr " $_h " x-vercel-id) " ] ; then
echo "FAIL x-vercel-id present on the apex — still Vercel (stale DNS, not a pass)" ; _rc = 1
else
echo "PASS no x-vercel-id on the apex"
fi
_can = " $( $CURL -L " $_apex / " 2>/dev/null | tr -d '\n' \
| sed -n 's/.*rel="canonical" href="\([^"]*\)".*/\1/p' | head -1) "
case " $_can " in
https://www.pcbjam.com/*| https://www.pcbjam.com)
echo " PASS apex declares canonical $_can " ; ;
*)
echo " FAIL apex canonical is ' ${ _can :- absent } ' — must point at www, otherwise the "
echo " two hostnames compete instead of consolidating" ; _rc = 1 ; ;
esac
# The demo cross-posts to www, but if anyone ever points it at the apex, the
# endpoint has to be reachable there too. Cheap to confirm.
_pre = " $( $CURL -o /dev/null -w '%{http_code}' -X OPTIONS " $_apex /api/waitlist " \
-H 'Origin: https://demo.pcbjam.com' \
-H 'Access-Control-Request-Method: POST' 2>/dev/null || true ) "
[ " $_pre " = "204" ] && echo "PASS apex /api/waitlist preflight 204" \
|| { echo " FAIL apex /api/waitlist preflight $_pre " ; _rc = 1; }
return $_rc
}
chore(deploy/site): retire the migration scaffolding, keep the health check
The Vercel -> Cloudflare Pages move is done and the Vercel project is
deleted, so the one-shot scripts have no remaining purpose. Nothing in CI
ever called them — deploy-site.yml runs npm ci / test / build / pages
deploy inline — so this removes 10 files and orphans nothing.
Deleted: 00-baseline (refused to run without x-vercel-id, so permanently
unrunnable), 01-preflight (proved Vercel state and API-token scopes),
07-dns-cutover (the phased cutover; in the end the records were attached
through the dashboard, and the rules/apex phases went unused once we chose
APEX_MODE=serve), 09-detach-vercel (its target project is gone), plus
03-ensure-project, 04-set-secrets, 05-deploy, 06-verify-deploy,
02-verify-local and 99-rollback, all either spent or duplicating CI. Their
lib/cf-api.sh went with them: the survivors use wrangler, so the whole
remaining path needs only `wrangler login` and no zone scopes.
What is kept is the part with ongoing value: lib/parity.sh, the assertion
set that caught five real defects during the migration — the live COOP/COEP
bug on the post's canonical URL, the soft-404 Pages would have introduced,
the cross-site form-POST guard Vercel had been providing for free, the
missing immutable header, and HSTS max-age=0. "Does the page return 200"
catches none of those.
08-verify-prod.sh becomes verify.sh, since the numbered sequence it
belonged to no longer exists. It drops the stamp machinery, the dry-run
plumbing and the Vercel-fallback messaging (there is no fallback now:
recovery is promoting a previous Pages deployment), and gains --skip-dns /
--skip-domains so it can be pointed at a single deployment via PROD_BASE
before promoting it.
The README is rewritten around the four invariants that fail SILENTLY —
never widen _headers to /*, keep both URL forms of the Gerber post, never
delete 404.astro, keep the cross-site form-POST guard — each with the
reason, since the reason is the only thing that stops someone simplifying
them back out.
Verified after: 21 probes, 20 pass, 1 warn (HSTS max-age is 6 months vs
Vercel's 2 years — on, just shorter), 0 fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 15:08:56 +02:00
# APEX_MODE=redirect: apex -> www, path + query preserved. Unused with the
# current `serve` topology; kept in case that decision is revisited.
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
2026-07-27 13:34:12 +02:00
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
}