pcbjam/deploy/site/lib/cf-api.sh

95 lines
4.2 KiB
Shell
Raw Normal View History

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
# 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"
# True when a REST token is available. `wrangler login` gives an OAuth token that
# wrangler itself uses but that cannot be replayed as a REST bearer token, and its
# zone scope is read-only — so the DNS/ruleset/HSTS steps need a real API token
# while the Pages steps are happy with either. Scripts branch on this.
cf_have_token() { [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]; }
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
cf_token() {
[ -n "${CLOUDFLARE_API_TOKEN:-}" ] || die "CLOUDFLARE_API_TOKEN is not set.
\`wrangler login\` is not enough for this step: its OAuth token cannot be used
as a REST bearer token, and it only carries zone:read. Create an API token with
the scopes at the top of this file and export it (plus CLOUDFLARE_ACCOUNT_ID)."
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
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"; }