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
86 lines
3.5 KiB
Shell
86 lines
3.5 KiB
Shell
#!/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"; }
|