Functions as an API — and the public/private split
This lesson is where a static site becomes a system. Two ideas carry it: routes are files, and the rule that must hold everywhere lives in exactly one place.
Routes are files
A file under functions/ becomes a route. No router, no registration, no config:
functions/
_middleware.js → runs before every request
api/
mcp.js → POST /api/mcp
health/
daily.js → /api/health/daily
wellbeing.js → /api/health/wellbeing
weight.js → /api/health/weight
A handler is a function per HTTP verb, handed a context with your bindings on it:
export async function onRequestGet({ env, request }) {
const url = new URL(request.url);
const day = url.searchParams.get("day") || new Date().toISOString().slice(0, 10);
const { results } = await env.DB
.prepare("SELECT kind, value, unit, source FROM measurements WHERE day = ?1 ORDER BY taken_utc")
.bind(day)
.all();
return Response.json({ day, rows: results });
}
env.DB is the D1 binding you declared in wrangler.toml. Note .bind() — never build SQL by string concatenation, not even on a single-user project. The habit is the point.
One middleware, one rule
functions/_middleware.js runs before everything. This is where authentication belongs, and the argument for it is not tidiness:
const PUBLIC_GET_APIS = new Set([
"/api/health/daily",
"/api/health/summary",
"/api/health/wellbeing",
"/api/health/sleep",
]);
export async function onRequest(context) {
const { request, next, env } = context;
const url = new URL(request.url);
// static files are public, always
if (!url.pathname.startsWith("/api/")) return next();
// an explicit allowlist — GET only, listed by hand
if (request.method === "GET" && PUBLIC_GET_APIS.has(url.pathname)) return next();
// everything else needs a credential
const auth = await checkAuth(request, env);
if (!auth.ok) return new Response("Unauthorized", { status: 401 });
context.data.auth = auth; // handlers can see who this is
return next();
}
Read the shape of that, because it is the whole security posture in eight lines: default deny, with a hand-written list of exceptions. It is lesson 8.3's allow-list rule applied to routing. A new endpoint is private until someone deliberately makes it public — which is the direction you want to fail in.
method === "GET" check. Without it, the allowlist would expose POST /api/health/daily too — a writer, not a reader. Public means readable, never writable.Redaction happens on the server
Now the part that most self-taught builders get wrong. A public endpoint returns real data — and simply never asks for the sensitive columns:
export async function onRequestGet({ env, request, data }) {
const isAuthed = Boolean(data.auth);
const cols = isAuthed
? "day, weight, systolic, diastolic, meds_note, clinician_note"
: "day, weight"; // the private columns are never SELECTed
const { results } = await env.DB
.prepare(`SELECT ${cols} FROM health_daily ORDER BY day DESC LIMIT 30`)
.all();
return Response.json({ rows: results, redacted: !isAuthed });
}
Two details worth copying. The sensitive columns are absent from the query, not filtered from the result — so a future refactor cannot accidentally leak them. And the response says redacted: true, so the dashboard can be honest about what it is showing instead of silently drawing an incomplete picture.
| Approach | What an attacker sees | Is it privacy? |
|---|---|---|
| Field hidden with CSS | Everything, in the network tab | No |
| Field removed in JavaScript | Everything, in the network tab | No |
| Field filtered from the API response | Nothing — until one refactor forgets the filter | Fragile |
| Field never SELECTed | Nothing | Yes |
Two things that will cost you an hour each
New route files propagate slower than static edits. A brand-new file under functions/ can take 40–90 seconds longer to go live than an HTML change in the same push. A 404 immediately after a deploy usually means "not yet", not "broken". Confirm the commit actually shipped before you start debugging something that is fine:
git log --oneline -1 # is this really what I pushed?
curl -s -o /dev/null -w '%{http_code}\n' https://your-site/api/health/daily
wrangler wants the token exported, not merely present. A token sitting in a sourced .env is not always in the environment of the subprocess. The reliable pattern:
set -a; source ~/.secrets/cf.env; set +a
npx wrangler d1 execute health-db --remote --command "SELECT COUNT(*) FROM measurements"