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

30
site/.dev.vars.example Normal file
View 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=

View file

@ -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
View file

@ -1,16 +1,24 @@
# build output
dist/
.vercel/
# generated content collection types & cache
.astro/
# wrangler local state/cache + migration scratch
.wrangler/
.cf-migrate/
# dependencies
node_modules/
# environment
.env
.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
.DS_Store

View file

@ -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
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
switched to per-request server rendering without ripping anything out (see
below).
- No adapter and no server bundle: the build emits `dist/` only. The single
dynamic endpoint, `/api/waitlist`, ships as a **Cloudflare Pages Function**
from `functions/` (see below).
## Routes
@ -38,38 +38,60 @@ Requires **Node ≥ 22.12** (Astro 6 requirement).
cd site
npm install
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
```
## SSR per route
## Dynamic routes
Pages are static by default. To render a specific page or endpoint on demand
(per request, as a Vercel Function), add this to its frontmatter:
Every page is static. The one server-side endpoint is a **Cloudflare Pages
Function**, not an Astro SSR route:
```astro
---
export const prerender = false;
---
```
functions/api/waitlist.ts -> POST/OPTIONS/GET /api/waitlist
```
That's the only change needed — the `@astrojs/vercel` adapter in
`astro.config.mjs` already provides the server runtime. The rest of the site
stays static.
Pages maps the `functions/` tree to routes by path, and the handlers are named
exports (`onRequestPost`, `onRequestOptions`, `onRequestGet`). Config arrives on
`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
`vercel.json` is required**.
Locally, run the built site the way Pages will serve it (this is the only way to
exercise the Function, `astro dev` does not run `functions/`):
1. Push this repo to GitHub.
2. Vercel dashboard → **New Project** → import this repo.
3. Click **Edit** next to **Root Directory** and set it to **`site`**. This is
the standard way to deploy a project that lives in a subdirectory.
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.
```bash
cp .dev.vars.example .dev.vars # fill in as needed; gitignored
npm run build
npm run pages:dev # http://localhost:8788
```
Note: do **not** use `vercel.json` for URL rewrites with Astro — use Astro's
`redirects` option in `astro.config.mjs` instead.
## Deploying
`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`.

View file

@ -1,18 +1,21 @@
// @ts-check
import { defineConfig, envField } from 'astro/config';
import vercel from '@astrojs/vercel';
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
// Static by default (every page prerenders to HTML, zero client JS).
// The Vercel adapter is wired in so any single route can opt into
// per-request SSR later with `export const prerender = false;`.
// See README.md ("SSR per route") for how.
// Fully static: every page prerenders to HTML with zero client JS, and there is
// NO adapter — the build output in dist/ is what `wrangler pages deploy` ships.
// The one dynamic route, /api/waitlist, is a Cloudflare Pages Function in
// functions/ rather than an Astro SSR route. See README.md ("Deploying").
export default defineConfig({
// Canonical origin (apex 308s to www). Without this, prerendered Astro.url
// is localhost, which leaked into canonical/OG tags on production.
site: 'https://www.pcbjam.com',
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
// inline (markdown posts can't import components).
integrations: [mdx()],
@ -20,9 +23,9 @@ export default defineConfig({
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
// Dev-server cross-origin isolation so the embedded Gerber viewer's WASM
// threads (SharedArrayBuffer) work under `npm run dev`. Production headers are
// scoped per-route in vercel.json. require-corp (not credentialless) for the
// widest browser support incl. Safari 15.2+; safe because the site loads only
// same-origin subresources.
// scoped per-route in public/_headers. require-corp (not credentialless) for
// the widest browser support incl. Safari 15.2+; safe because the site loads
// only same-origin subresources.
vite: {
server: {
headers: {
@ -31,29 +34,8 @@ export default defineConfig({
},
},
},
// Typed, validated server secrets for the waitlist endpoint. All optional so
// the build never requires them and the endpoint degrades gracefully when a
// key is absent (see src/pages/api/waitlist.ts). The @astrojs/vercel adapter
// reads these from process.env at runtime — never inlined into the bundle.
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',
}),
},
},
// No `env.schema`: the waitlist secrets are no longer read through
// `astro:env/server`. They reach the Pages Function as bindings on its
// `context.env` (`wrangler pages secret put`), and the two former schema
// defaults now live in functions/api/waitlist.ts.
});

View file

@ -1,19 +1,33 @@
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;
/**
* Waitlist endpoint — a Cloudflare Pages Function.
*
* 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]+$/;
@ -30,9 +44,9 @@ function maskEmail(email: string): string {
/**
* 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.
* bursts from one warm isolate. NOT a complete defense (fresh isolates don't
* share it), so production should ALSO front this with a shared store or a
* CAPTCHA. Kept dependency-free so it runs.
*/
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const RATE_MAX_PER_IP = 5;
@ -50,8 +64,14 @@ function rateLimited(key: string, max: number, now: number): boolean {
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 {
const cf = request.headers.get('cf-connecting-ip');
if (cf) return cf;
const xff = request.headers.get('x-forwarded-for');
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
* 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.
*
* 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> {
const origin = request.headers.get('origin');
if (!origin) return {};
const allowed = WAITLIST_ALLOWED_ORIGINS.split(',')
function allowedOrigins(env: Env): string[] {
return (env.WAITLIST_ALLOWED_ORIGINS ?? DEFAULT_ALLOWED_ORIGINS)
.split(',')
.map((o) => o.trim())
.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 {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'POST, OPTIONS',
@ -97,18 +124,44 @@ function redirect(status: 'ok' | 'error') {
});
}
export const OPTIONS: APIRoute = ({ request }) =>
new Response(null, { status: 204, headers: corsHeaders(request) });
export const onRequestOptions: PagesFunction<Env> = ({ request, env }) =>
new Response(null, { status: 204, headers: corsHeaders(request, env) });
export const POST: APIRoute = async ({ request }) => {
const cors = corsHeaders(request);
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
const cors = corsHeaders(request, env);
const ct = request.headers.get('content-type') ?? '';
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> = {};
try {
data = wantsJson
? await request.json()
? ((await request.json()) as Record<string, unknown>)
: Object.fromEntries(await request.formData());
} catch {
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 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 (!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.
if (!RESEND_API_KEY) {
if (!env.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);
const resend = new Resend(env.RESEND_API_KEY);
try {
// 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
// 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) {
if (env.RESEND_SEGMENT_ID) {
const { error: contactError } = await resend.contacts.create({
email,
unsubscribed: false,
segments: [{ id: RESEND_SEGMENT_ID }],
segments: [{ id: env.RESEND_SEGMENT_ID }],
});
if (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({
from: WAITLIST_FROM_EMAIL,
from: env.WAITLIST_FROM_EMAIL ?? DEFAULT_FROM_EMAIL,
to: email,
subject: "You're on the PCBJam waitlist",
text: [
@ -199,4 +255,5 @@ export const POST: APIRoute = async ({ request }) => {
};
// 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
View 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

File diff suppressed because it is too large Load diff

View file

@ -12,17 +12,18 @@
"preview": "astro preview",
"check": "astro check",
"astro": "astro",
"test": "vitest run"
"test": "vitest run",
"pages:dev": "npx --yes wrangler@4 pages dev"
},
"dependencies": {
"@astrojs/mdx": "^6.0.3",
"@astrojs/vercel": "^10.0.8",
"@fontsource/oswald": "^5.2.8",
"astro": "^6.4.4",
"resend": "^6.12.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.9",
"@cloudflare/workers-types": "^4.20260610.1",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "^26.1.0",
"happy-dom": "^15.11.6",

35
site/public/_headers Normal file
View 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
View file

@ -0,0 +1,5 @@
{
"version": 1,
"include": ["/api/*"],
"exclude": []
}

View file

@ -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/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`). |
| `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

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

@ -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';

View file

@ -1,19 +1,14 @@
import { describe, expect, it, vi } from "vitest";
/**
* Waitlist route hardening: per-key rate limiting, and no raw address in logs on
* the no-key path.
* Waitlist Function hardening: per-key rate limiting, and no raw address in logs
* on the no-key path.
*
* The route imports typed Astro env + the Resend SDK; both are stubbed so the
* handler runs in plain node. RESEND_API_KEY is left undefined so the no-key
* The endpoint is a Cloudflare Pages Function, so its config arrives as an `env`
* 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.
*/
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", () => ({
Resend: class {
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> {
const request = new Request("https://www.pcbjam.com/api/waitlist", {
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 }),
});
// Astro passes a full context; the handler only reads `request`.
return POST({ request } as unknown as Parameters<typeof POST>[0]) as Promise<Response>;
// Pages passes a full EventContext; the handler only reads `request` and `env`.
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 () => {
const ip = "203.0.113.9";
const statuses: number[] = [];
@ -50,4 +63,56 @@ describe("waitlist route hardening", () => {
expect(logged).toContain("@example.com"); // domain kept for debugging
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();
});
});

View file

@ -1,5 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
"exclude": ["dist", ".wrangler", ".cf-migrate"]
}

View file

@ -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
View 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"]