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
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
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",
|
||||
"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
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/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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
* 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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"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