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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue