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:

A rule each route has to remember to apply will eventually be forgotten by exactly one route. That route is the one someone finds. Put the rule where forgetting is not possible.
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.

Note the 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.

ApproachWhat an attacker seesIs it privacy?
Field hidden with CSSEverything, in the network tabNo
Field removed in JavaScriptEverything, in the network tabNo
Field filtered from the API responseNothing — until one refactor forgets the filterFragile
Field never SELECTedNothingYes

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"

Where next