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:
parent
2e1b69998f
commit
7edfade53c
41 changed files with 2443 additions and 910 deletions
120
.github/workflows/deploy-site.yml
vendored
Normal file
120
.github/workflows/deploy-site.yml
vendored
Normal 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; }
|
||||||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -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.
|
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
77
deploy/site/00-baseline.sh
Executable 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
118
deploy/site/01-preflight.sh
Executable 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
121
deploy/site/02-verify-local.sh
Executable 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"
|
||||||
57
deploy/site/03-ensure-project.sh
Executable file
57
deploy/site/03-ensure-project.sh
Executable 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
104
deploy/site/04-set-secrets.sh
Executable 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
71
deploy/site/05-deploy.sh
Executable 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
95
deploy/site/06-verify-deploy.sh
Executable 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
347
deploy/site/07-dns-cutover.sh
Executable 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
87
deploy/site/08-verify-prod.sh
Executable 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
96
deploy/site/09-detach-vercel.sh
Executable 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
72
deploy/site/99-rollback.sh
Executable 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
139
deploy/site/README.md
Normal 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? |
|
||||||
|
|---|---|---|
|
||||||
|
| T−days | `00-baseline.sh` | no — read-only |
|
||||||
|
| T−days | `01-preflight.sh` | no — read-only |
|
||||||
|
| T−days | `07-dns-cutover.sh --phase probe --apply` | no — throwaway hostname |
|
||||||
|
| T−days | `02-verify-local.sh` | no |
|
||||||
|
| T−days | `03-ensure-project.sh --apply` → `04-set-secrets.sh --apply` | new project only |
|
||||||
|
| T−days | `05-deploy.sh --preview --apply` → `06-verify-deploy.sh --latest` | no — pages.dev only |
|
||||||
|
| T−1d | `07-dns-cutover.sh --phase rules --apply` | no — inert until the apex is proxied |
|
||||||
|
| T−1d | `07-dns-cutover.sh --phase hsts --apply` | no — additive |
|
||||||
|
| T−24h | `07-dns-cutover.sh --phase prelower --apply` | no — TTL only |
|
||||||
|
| T−1h | `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
86
deploy/site/lib/cf-api.sh
Normal 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
136
deploy/site/lib/common.sh
Normal 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
282
deploy/site/lib/parity.sh
Normal 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
30
site/.dev.vars.example
Normal 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=
|
||||||
|
|
@ -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
10
site/.gitignore
vendored
|
|
@ -1,16 +1,24 @@
|
||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
.vercel/
|
|
||||||
|
|
||||||
# generated content collection types & cache
|
# generated content collection types & cache
|
||||||
.astro/
|
.astro/
|
||||||
|
|
||||||
|
# wrangler local state/cache + migration scratch
|
||||||
|
.wrangler/
|
||||||
|
.cf-migrate/
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# environment
|
# environment
|
||||||
.env
|
.env
|
||||||
.env.production
|
.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
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -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
|
- Static by default: every page is prerendered to HTML and ships **zero client
|
||||||
JavaScript**. A visitor downloads HTML + CSS only — no React/JS bundle.
|
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
|
- No adapter and no server bundle: the build emits `dist/` only. The single
|
||||||
switched to per-request server rendering without ripping anything out (see
|
dynamic endpoint, `/api/waitlist`, ships as a **Cloudflare Pages Function**
|
||||||
below).
|
from `functions/` (see below).
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
|
|
@ -38,38 +38,60 @@ Requires **Node ≥ 22.12** (Astro 6 requirement).
|
||||||
cd site
|
cd site
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:4321
|
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
|
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
|
Every page is static. The one server-side endpoint is a **Cloudflare Pages
|
||||||
(per request, as a Vercel Function), add this to its frontmatter:
|
Function**, not an Astro SSR route:
|
||||||
|
|
||||||
```astro
|
```
|
||||||
---
|
functions/api/waitlist.ts -> POST/OPTIONS/GET /api/waitlist
|
||||||
export const prerender = false;
|
|
||||||
---
|
|
||||||
```
|
```
|
||||||
|
|
||||||
That's the only change needed — the `@astrojs/vercel` adapter in
|
Pages maps the `functions/` tree to routes by path, and the handlers are named
|
||||||
`astro.config.mjs` already provides the server runtime. The rest of the site
|
exports (`onRequestPost`, `onRequestOptions`, `onRequestGet`). Config arrives on
|
||||||
stays static.
|
`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
|
Locally, run the built site the way Pages will serve it (this is the only way to
|
||||||
`vercel.json` is required**.
|
exercise the Function, `astro dev` does not run `functions/`):
|
||||||
|
|
||||||
1. Push this repo to GitHub.
|
```bash
|
||||||
2. Vercel dashboard → **New Project** → import this repo.
|
cp .dev.vars.example .dev.vars # fill in as needed; gitignored
|
||||||
3. Click **Edit** next to **Root Directory** and set it to **`site`**. This is
|
npm run build
|
||||||
the standard way to deploy a project that lives in a subdirectory.
|
npm run pages:dev # http://localhost:8788
|
||||||
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.
|
|
||||||
|
|
||||||
Note: do **not** use `vercel.json` for URL rewrites with Astro — use Astro's
|
## Deploying
|
||||||
`redirects` option in `astro.config.mjs` instead.
|
|
||||||
|
`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`.
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig, envField } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import vercel from '@astrojs/vercel';
|
|
||||||
import mdx from '@astrojs/mdx';
|
import mdx from '@astrojs/mdx';
|
||||||
|
|
||||||
// Static by default (every page prerenders to HTML, zero client JS).
|
// Fully static: every page prerenders to HTML with zero client JS, and there is
|
||||||
// The Vercel adapter is wired in so any single route can opt into
|
// NO adapter — the build output in dist/ is what `wrangler pages deploy` ships.
|
||||||
// per-request SSR later with `export const prerender = false;`.
|
// The one dynamic route, /api/waitlist, is a Cloudflare Pages Function in
|
||||||
// See README.md ("SSR per route") for how.
|
// functions/ rather than an Astro SSR route. See README.md ("Deploying").
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// Canonical origin (apex 308s to www). Without this, prerendered Astro.url
|
// Canonical origin (apex 308s to www). Without this, prerendered Astro.url
|
||||||
// is localhost, which leaked into canonical/OG tags on production.
|
// is localhost, which leaked into canonical/OG tags on production.
|
||||||
site: 'https://www.pcbjam.com',
|
site: 'https://www.pcbjam.com',
|
||||||
output: 'static',
|
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
|
// MDX lets the Gerber-viewer blog post embed the <GerberDemo /> component
|
||||||
// inline (markdown posts can't import components).
|
// inline (markdown posts can't import components).
|
||||||
integrations: [mdx()],
|
integrations: [mdx()],
|
||||||
|
|
@ -20,9 +23,9 @@ export default defineConfig({
|
||||||
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
|
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
|
||||||
// Dev-server cross-origin isolation so the embedded Gerber viewer's WASM
|
// Dev-server cross-origin isolation so the embedded Gerber viewer's WASM
|
||||||
// threads (SharedArrayBuffer) work under `npm run dev`. Production headers are
|
// threads (SharedArrayBuffer) work under `npm run dev`. Production headers are
|
||||||
// scoped per-route in vercel.json. require-corp (not credentialless) for the
|
// scoped per-route in public/_headers. require-corp (not credentialless) for
|
||||||
// widest browser support incl. Safari 15.2+; safe because the site loads only
|
// the widest browser support incl. Safari 15.2+; safe because the site loads
|
||||||
// same-origin subresources.
|
// only same-origin subresources.
|
||||||
vite: {
|
vite: {
|
||||||
server: {
|
server: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -31,29 +34,8 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Typed, validated server secrets for the waitlist endpoint. All optional so
|
// No `env.schema`: the waitlist secrets are no longer read through
|
||||||
// the build never requires them and the endpoint degrades gracefully when a
|
// `astro:env/server`. They reach the Pages Function as bindings on its
|
||||||
// key is absent (see src/pages/api/waitlist.ts). The @astrojs/vercel adapter
|
// `context.env` (`wrangler pages secret put`), and the two former schema
|
||||||
// reads these from process.env at runtime — never inlined into the bundle.
|
// defaults now live in functions/api/waitlist.ts.
|
||||||
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',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { Resend } from 'resend';
|
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.
|
* Waitlist endpoint — a Cloudflare Pages Function.
|
||||||
export const prerender = false;
|
*
|
||||||
|
* 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]+$/;
|
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
|
* 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
|
* bursts from one warm isolate. NOT a complete defense (fresh isolates don't
|
||||||
* instances don't share it), so production should ALSO front this with a shared
|
* share it), so production should ALSO front this with a shared store or a
|
||||||
* store (Vercel KV / Upstash) or a CAPTCHA. Kept dependency-free so it runs.
|
* CAPTCHA. Kept dependency-free so it runs.
|
||||||
*/
|
*/
|
||||||
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const RATE_MAX_PER_IP = 5;
|
const RATE_MAX_PER_IP = 5;
|
||||||
|
|
@ -50,8 +64,14 @@ function rateLimited(key: string, max: number, now: number): boolean {
|
||||||
return false;
|
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 {
|
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');
|
const xff = request.headers.get('x-forwarded-for');
|
||||||
return (xff ? xff.split(',')[0] : '').trim() || 'unknown';
|
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
|
* 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
|
* 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.
|
* 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> {
|
function allowedOrigins(env: Env): string[] {
|
||||||
const origin = request.headers.get('origin');
|
return (env.WAITLIST_ALLOWED_ORIGINS ?? DEFAULT_ALLOWED_ORIGINS)
|
||||||
if (!origin) return {};
|
.split(',')
|
||||||
const allowed = WAITLIST_ALLOWED_ORIGINS.split(',')
|
|
||||||
.map((o) => o.trim())
|
.map((o) => o.trim())
|
||||||
.filter(Boolean);
|
.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 {
|
return {
|
||||||
'access-control-allow-origin': origin,
|
'access-control-allow-origin': origin,
|
||||||
'access-control-allow-methods': 'POST, OPTIONS',
|
'access-control-allow-methods': 'POST, OPTIONS',
|
||||||
|
|
@ -97,18 +124,44 @@ function redirect(status: 'ok' | 'error') {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OPTIONS: APIRoute = ({ request }) =>
|
export const onRequestOptions: PagesFunction<Env> = ({ request, env }) =>
|
||||||
new Response(null, { status: 204, headers: corsHeaders(request) });
|
new Response(null, { status: 204, headers: corsHeaders(request, env) });
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
|
||||||
const cors = corsHeaders(request);
|
const cors = corsHeaders(request, env);
|
||||||
const ct = request.headers.get('content-type') ?? '';
|
const ct = request.headers.get('content-type') ?? '';
|
||||||
const wantsJson = ct.includes('application/json');
|
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> = {};
|
let data: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
data = wantsJson
|
data = wantsJson
|
||||||
? await request.json()
|
? ((await request.json()) as Record<string, unknown>)
|
||||||
: Object.fromEntries(await request.formData());
|
: Object.fromEntries(await request.formData());
|
||||||
} catch {
|
} catch {
|
||||||
return wantsJson ? json(400, { ok: false, error: 'bad_request' }, cors) : redirect('error');
|
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 honeypot = String(data.company_url ?? ''); // hidden field — must stay empty
|
||||||
const source = String(data.source ?? 'unknown');
|
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 (honeypot) return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
|
||||||
|
|
||||||
if (!EMAIL_RE.test(email) || email.length > 254) {
|
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.
|
// 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(
|
console.warn(
|
||||||
`[waitlist] RESEND_API_KEY not set — skipping send. email=${maskEmail(email)} source=${source}`,
|
`[waitlist] RESEND_API_KEY not set — skipping send. email=${maskEmail(email)} source=${source}`,
|
||||||
);
|
);
|
||||||
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
|
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 {
|
try {
|
||||||
// Add to the segment (the modern name for an "audience"). The SDK returns
|
// 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
|
// 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
|
// contact step as best-effort: log any error but never fail the request on
|
||||||
// it — the user-facing promise is the confirmation email below.
|
// 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({
|
const { error: contactError } = await resend.contacts.create({
|
||||||
email,
|
email,
|
||||||
unsubscribed: false,
|
unsubscribed: false,
|
||||||
segments: [{ id: RESEND_SEGMENT_ID }],
|
segments: [{ id: env.RESEND_SEGMENT_ID }],
|
||||||
});
|
});
|
||||||
if (contactError) {
|
if (contactError) {
|
||||||
console.error('[waitlist] contacts.create failed (non-fatal)', 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({
|
const { error: sendError } = await resend.emails.send({
|
||||||
from: WAITLIST_FROM_EMAIL,
|
from: env.WAITLIST_FROM_EMAIL ?? DEFAULT_FROM_EMAIL,
|
||||||
to: email,
|
to: email,
|
||||||
subject: "You're on the PCBJam waitlist",
|
subject: "You're on the PCBJam waitlist",
|
||||||
text: [
|
text: [
|
||||||
|
|
@ -199,4 +255,5 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// A bare GET (e.g. someone visiting the URL) shouldn't 500.
|
// 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
4
site/functions/env.d.ts
vendored
Normal 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
752
site/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -12,17 +12,18 @@
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"pages:dev": "npx --yes wrangler@4 pages dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^6.0.3",
|
"@astrojs/mdx": "^6.0.3",
|
||||||
"@astrojs/vercel": "^10.0.8",
|
|
||||||
"@fontsource/oswald": "^5.2.8",
|
"@fontsource/oswald": "^5.2.8",
|
||||||
"astro": "^6.4.4",
|
"astro": "^6.4.4",
|
||||||
"resend": "^6.12.4"
|
"resend": "^6.12.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.9",
|
"@astrojs/check": "^0.9.9",
|
||||||
|
"@cloudflare/workers-types": "^4.20260610.1",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@types/node": "^26.1.0",
|
"@types/node": "^26.1.0",
|
||||||
"happy-dom": "^15.11.6",
|
"happy-dom": "^15.11.6",
|
||||||
|
|
|
||||||
35
site/public/_headers
Normal file
35
site/public/_headers
Normal 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
5
site/public/_routes.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"include": ["/api/*"],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
|
|
@ -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/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. |
|
| `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`). |
|
| `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
|
## Dev overrides
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Build provenance: the pcbjam commit this site was built from — it pins the
|
||||||
// KiCad + wxWidgets submodule revisions implicitly and is our GPLv3
|
// KiCad + wxWidgets submodule revisions implicitly and is our GPLv3
|
||||||
// corresponding-source pointer (see /licenses). Resolved at build time:
|
// corresponding-source pointer (see /licenses), so it must not silently degrade
|
||||||
// Vercel sets VERCEL_GIT_COMMIT_SHA; local builds fall back to git.
|
// 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 {
|
function resolveBuildSha(): string {
|
||||||
const vercelSha = process.env.VERCEL_GIT_COMMIT_SHA;
|
const ciSha = process.env.CF_PAGES_COMMIT_SHA || process.env.GITHUB_SHA;
|
||||||
if (vercelSha) return vercelSha.slice(0, 7);
|
if (ciSha) return ciSha.slice(0, 7);
|
||||||
try {
|
try {
|
||||||
return execSync('git rev-parse --short=7 HEAD', { encoding: 'utf8' }).trim();
|
return execSync('git rev-parse --short=7 HEAD', { encoding: 'utf8' }).trim();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* The live viewer needs cross-origin isolation (SharedArrayBuffer + pthreads)
|
* The live viewer needs cross-origin isolation (SharedArrayBuffer + pthreads)
|
||||||
* and WebGL2. We feature-detect and degrade gracefully: capable browsers get the
|
* 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
|
* 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
|
* 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
|
* NOT cross-origin isolated), the client script does one guarded reload so the
|
||||||
* route headers take effect.
|
* route headers take effect.
|
||||||
|
|
@ -148,7 +148,7 @@ const caption =
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reloaded moments ago and STILL not isolated (stale cache, missing headers,
|
// 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
|
// 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.
|
// button open it in a new tab — capable browsers get the full viewer there.
|
||||||
if (launchEl) {
|
if (launchEl) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
title: Cookie Policy
|
title: Cookie Policy
|
||||||
description: How PCBJam uses cookies and similar technologies, and the choices you have.
|
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.
|
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.
|
**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)
|
### 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. |
|
| **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) |
|
| **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.
|
We are not responsible for the privacy practices of these third parties; please review their notices. We update this list as our integrations change.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
title: Privacy Policy
|
title: Privacy Policy
|
||||||
description: How PCBJam handles your personal data.
|
description: How PCBJam handles your personal data.
|
||||||
updated: 2026-07-15
|
updated: 2026-07-27
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. A quick summary
|
## 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 |
|
| 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) |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **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 | — |
|
| **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 | — |
|
| **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
|
## 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 **EU–US Data Privacy Framework** for certified US providers); or
|
- an **adequacy decision** by the European Commission (including, where applicable, the **EU–US 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.
|
- the European Commission's **Standard Contractual Clauses** (and the UK International Data Transfer Addendum), together with additional safeguards where needed.
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ const ogImageAbs = new URL(ogImage, Astro.site ?? Astro.url).href;
|
||||||
pcbjam properties. Prod builds only — `astro dev` stays untracked, and
|
pcbjam properties. Prod builds only — `astro dev` stays untracked, and
|
||||||
Plausible ignores localhost regardless. Auto-tracks pageviews across
|
Plausible ignores localhost regardless. Auto-tracks pageviews across
|
||||||
ClientRouter navigations (History API). crossorigin="anonymous" so the
|
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. */}
|
cross-origin subresources there must be fetched with CORS. */}
|
||||||
{
|
{
|
||||||
import.meta.env.PROD && (
|
import.meta.env.PROD && (
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { defineMiddleware } from 'astro:middleware';
|
||||||
* cross-origin isolated, and the embedded Gerber viewer's SharedArrayBuffer
|
* cross-origin isolated, and the embedded Gerber viewer's SharedArrayBuffer
|
||||||
* would be unavailable. Set them here for `npm run dev`.
|
* 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
|
* scoped to the post + /gerber-demo routes, so we no-op outside dev to keep the
|
||||||
* prerender/build output untouched.
|
* prerender/build output untouched.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
46
site/src/pages/404.astro
Normal file
46
site/src/pages/404.astro
Normal 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>
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* deliberately do NOT apply to the landing page: that would block the YouTube
|
* 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).
|
* 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
|
* 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';
|
import SectionBand from '../components/SectionBand.astro';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,14 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Waitlist route hardening: per-key rate limiting, and no raw address in logs on
|
* Waitlist Function hardening: per-key rate limiting, and no raw address in logs
|
||||||
* the no-key path.
|
* on the no-key path.
|
||||||
*
|
*
|
||||||
* The route imports typed Astro env + the Resend SDK; both are stubbed so the
|
* The endpoint is a Cloudflare Pages Function, so its config arrives as an `env`
|
||||||
* handler runs in plain node. RESEND_API_KEY is left undefined so the no-key
|
* 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.
|
* 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", () => ({
|
vi.mock("resend", () => ({
|
||||||
Resend: class {
|
Resend: class {
|
||||||
emails = { send: async () => ({}) };
|
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> {
|
function post(email: string, ip: string): Promise<Response> {
|
||||||
const request = new Request("https://www.pcbjam.com/api/waitlist", {
|
const request = new Request("https://www.pcbjam.com/api/waitlist", {
|
||||||
method: "POST",
|
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 }),
|
body: JSON.stringify({ email }),
|
||||||
});
|
});
|
||||||
// Astro passes a full context; the handler only reads `request`.
|
// Pages passes a full EventContext; the handler only reads `request` and `env`.
|
||||||
return POST({ request } as unknown as Parameters<typeof POST>[0]) as Promise<Response>;
|
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 () => {
|
it("rate-limits a burst from one IP (429 after the per-IP cap)", async () => {
|
||||||
const ip = "203.0.113.9";
|
const ip = "203.0.113.9";
|
||||||
const statuses: number[] = [];
|
const statuses: number[] = [];
|
||||||
|
|
@ -50,4 +63,56 @@ describe("waitlist route hardening", () => {
|
||||||
expect(logged).toContain("@example.com"); // domain kept for debugging
|
expect(logged).toContain("@example.com"); // domain kept for debugging
|
||||||
warn.mockRestore();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [".astro/types.d.ts", "**/*"],
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
"exclude": ["dist"]
|
"exclude": ["dist", ".wrangler", ".cf-migrate"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
21
site/wrangler.toml
Normal 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"]
|
||||||
Loading…
Reference in a new issue