feat(site): move the marketing site from Vercel to Cloudflare Pages

www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.

The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.

Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.

Four behaviour differences were found by measurement and are handled here:

- The blog post's COOP/COEP was already broken in production. vercel.json
  scoped the headers to the bare URL, but the page's own canonical is the
  trailing-slash form, which served 200 with no isolation headers — so
  search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
  degraded. public/_headers covers both forms.

- Pages answers unknown URLs with the homepage at HTTP 200 when the
  output has no 404.html — a soft-404 that invites indexing junk URLs as
  the homepage. Hence src/pages/404.astro.

- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
  submissions are forbidden"); Pages does not, and a cross-site <form>
  submit needs no CORS permission to be sent, so the allowlist cannot
  stop it. The Function reproduces the guard; JSON posts stay exempt as
  that is demo.pcbjam.com's allowlisted path.

- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
  generated route config, so it is now an explicit _headers rule.

Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.

privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
This commit is contained in:
Viktor Vaczi 2026-07-27 13:34:12 +02:00
commit 7edfade53c
41 changed files with 2443 additions and 910 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -1,202 +0,0 @@
import type { APIRoute } from 'astro';
import { Resend } from 'resend';
// Typed server secrets (schema in astro.config.mjs). Optional, so RESEND_API_KEY
// and RESEND_SEGMENT_ID are `string | undefined`; WAITLIST_FROM_EMAIL has a
// schema default so it's always a `string`. The Vercel adapter reads these from
// process.env at runtime — never inlined.
import {
RESEND_API_KEY,
RESEND_SEGMENT_ID,
WAITLIST_FROM_EMAIL,
WAITLIST_ALLOWED_ORIGINS,
} from 'astro:env/server';
// Opt this single route into on-demand (serverless) rendering. The rest of the
// site stays static; Vercel emits exactly one function for /api/waitlist.
export const prerender = false;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
/**
* Redact an address for logs: keep the domain (useful for debugging a
* misconfigured send) but mask the local-part so logs never hold the raw
* address.
*/
function maskEmail(email: string): string {
const at = email.indexOf('@');
if (at <= 0) return '***';
return `${email.slice(0, 1)}***@${email.slice(at + 1)}`;
}
/**
* Best-effort per-key rate limit — an in-process sliding window that bounds
* bursts from one warm instance. NOT a complete defense on serverless (fresh
* instances don't share it), so production should ALSO front this with a shared
* store (Vercel KV / Upstash) or a CAPTCHA. Kept dependency-free so it runs.
*/
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const RATE_MAX_PER_IP = 5;
const RATE_MAX_PER_EMAIL = 2;
const rateHits = new Map<string, number[]>();
function rateLimited(key: string, max: number, now: number): boolean {
const hits = (rateHits.get(key) ?? []).filter((t) => now - t < RATE_WINDOW_MS);
if (hits.length >= max) {
rateHits.set(key, hits);
return true;
}
hits.push(now);
rateHits.set(key, hits);
return false;
}
/** Client IP from the standard proxy headers (Vercel sets x-forwarded-for). */
function clientIp(request: Request): string {
const xff = request.headers.get('x-forwarded-for');
return (xff ? xff.split(',')[0] : '').trim() || 'unknown';
}
/**
* CORS headers when the request Origin is in the configured allowlist — lets the
* static demo (demo.pcbjam.com, no backend of its own) cross-post the form. A
* same-origin submit sends no Origin and gets no CORS headers (it doesn't need
* them). The JSON content-type triggers a preflight, hence the OPTIONS handler.
*/
function corsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('origin');
if (!origin) return {};
const allowed = WAITLIST_ALLOWED_ORIGINS.split(',')
.map((o) => o.trim())
.filter(Boolean);
if (!allowed.includes(origin)) return {};
return {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'POST, OPTIONS',
'access-control-allow-headers': 'content-type',
'access-control-max-age': '86400',
vary: 'Origin',
};
}
function json(
status: number,
body: Record<string, unknown>,
extra: Record<string, string> = {},
) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...extra },
});
}
function redirect(status: 'ok' | 'error') {
// No-JS native submit: bounce back to the page with a status flag.
return new Response(null, {
status: 303,
headers: { Location: `/?waitlist=${status}#waitlist` },
});
}
export const OPTIONS: APIRoute = ({ request }) =>
new Response(null, { status: 204, headers: corsHeaders(request) });
export const POST: APIRoute = async ({ request }) => {
const cors = corsHeaders(request);
const ct = request.headers.get('content-type') ?? '';
const wantsJson = ct.includes('application/json');
let data: Record<string, unknown> = {};
try {
data = wantsJson
? await request.json()
: Object.fromEntries(await request.formData());
} catch {
return wantsJson ? json(400, { ok: false, error: 'bad_request' }, cors) : redirect('error');
}
const email = String(data.email ?? '').trim().toLowerCase();
const honeypot = String(data.company_url ?? ''); // hidden field — must stay empty
const source = String(data.source ?? 'unknown');
// Bot caught by honeypot: silently "succeed" so we don't tip them off.
if (honeypot) return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
if (!EMAIL_RE.test(email) || email.length > 254) {
return wantsJson ? json(400, { ok: false, error: 'invalid_email' }, cors) : redirect('error');
}
// Rate limit: bound per-IP and per-address submissions. Checked after
// validation so invalid emails don't consume budget.
const now = Date.now();
const ip = clientIp(request);
if (
rateLimited(`ip:${ip}`, RATE_MAX_PER_IP, now) ||
rateLimited(`email:${email}`, RATE_MAX_PER_EMAIL, now)
) {
return wantsJson
? json(429, { ok: false, error: 'rate_limited' }, cors)
: redirect('error');
}
// No key configured (e.g. local dev without secrets): accept + log, don't 500.
if (!RESEND_API_KEY) {
console.warn(
`[waitlist] RESEND_API_KEY not set — skipping send. email=${maskEmail(email)} source=${source}`,
);
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
}
const resend = new Resend(RESEND_API_KEY);
try {
// Add to the segment (the modern name for an "audience"). The SDK returns
// { data, error } and does NOT throw on API errors. A duplicate contact has
// no stable error code (it surfaces as a validation_error), so we treat the
// contact step as best-effort: log any error but never fail the request on
// it — the user-facing promise is the confirmation email below.
if (RESEND_SEGMENT_ID) {
const { error: contactError } = await resend.contacts.create({
email,
unsubscribed: false,
segments: [{ id: RESEND_SEGMENT_ID }],
});
if (contactError) {
console.error('[waitlist] contacts.create failed (non-fatal)', contactError);
}
}
const { error: sendError } = await resend.emails.send({
from: WAITLIST_FROM_EMAIL,
to: email,
subject: "You're on the PCBJam waitlist",
text: [
"You're on the list. 🎉",
'',
"We'll send your early-access invite as seats open in waves, plus the occasional",
'product update. Every update includes an unsubscribe link — and you can reply',
'to this email at any time to be taken off the list.',
'',
"Didn't sign up? Just reply and we'll remove this address.",
'',
'— The PCBJam team, built by Emergence Engineering',
'https://pcbjam.com/privacy',
].join('\n'),
});
// The confirmation send IS the user-facing promise — fail loudly if Resend
// rejected it (bad key, unverified domain, invalid from-address, …).
if (sendError) {
console.error('[waitlist] emails.send failed', sendError);
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
}
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
} catch (err) {
// Defensive: unexpected throw (network error, bad construction).
console.error('[waitlist] unexpected error', err);
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
}
};
// A bare GET (e.g. someone visiting the URL) shouldn't 500.
export const GET: APIRoute = () => json(405, { ok: false, error: 'method_not_allowed' });

View file

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