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
118 lines
4.6 KiB
TypeScript
118 lines
4.6 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
/**
|
|
* Waitlist Function hardening: per-key rate limiting, and no raw address in logs
|
|
* on the no-key path.
|
|
*
|
|
* 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("resend", () => ({
|
|
Resend: class {
|
|
emails = { send: async () => ({}) };
|
|
contacts = { create: async () => ({}) };
|
|
},
|
|
}));
|
|
|
|
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",
|
|
// 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 }),
|
|
});
|
|
// 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 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[] = [];
|
|
for (let i = 0; i < 7; i++) statuses.push((await post(`u${i}@ex.com`, ip)).status);
|
|
expect(statuses.filter((s) => s === 200).length).toBe(5); // RATE_MAX_PER_IP
|
|
expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("does not write the raw email to logs in the no-key path", async () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
await post("secret.person@example.com", "198.51.100.7");
|
|
const logged = warn.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
expect(logged).not.toContain("secret.person@example.com"); // raw address masked
|
|
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();
|
|
});
|
|
});
|