This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
Five gaps closed: runtime validation on every API route with Zod, rate limiting on open endpoints, body size limits on every route that reads a request body, URL param validation, and consistent error response shapes across all routes.
Every API route was calling request.json() and trusting the shape blindly. On the response side, every client-side fetch helper used data as SomeType casts — TypeScript only enforces those at compile time, not at runtime. If the backend schema ever changed or a malformed payload arrived, the app would either silently mishandle the data or crash in a confusing way.
Beyond the type safety issue, none of the routes enforced a body size limit before calling request.json(). A client could send an arbitrarily large payload and the server would buffer the entire thing before even looking at it — a straightforward memory exhaustion vector.
Separately, two routes had no authentication at all: /api/vitals (the Web Vitals ingestion endpoint) and /api/graphql (the PokeAPI proxy). Both intentionally open — but with no throttle, either could be spammed to inflate metrics or hammer an upstream API.
Zod was already in the project but unused for validation. src/lib/schemas.ts now holds schemas for every shape that crosses a trust boundary — request bodies, response wrappers, and the GraphQL response envelope. Twenty-plus schemas covering events, calendars, members, cards, countdowns, and PokeAPI results.
On the API route side every POST and PUT handler now calls schema.safeParse(raw) before forwarding to the backend. A bad shape gets a 400 with Zod's issue list in the body — specific enough to actually debug. On the client side all the as Type casts in src/lib/calendar.ts, src/hooks/useCalendars.ts, and src/lib/graphql.ts are replaced with schema.parse(await res.json()). A backend schema change now throws a ZodError rather than silently passing bad data through.
safeParse on routes and parse on clientsOn API routes, throwing is not the right response — you want control over the HTTP status code and body. So safeParse returns a result object and the handler decides what to return. On the client, the response comes from our own backend, so a parse failure is genuinely unexpected — letting it throw and surface as an error in React Query is the right behavior.
src/lib/rateLimit.ts is a simple fixed-window counter — a module-level Map keyed by route:ip, reset after the window expires. A prune pass runs at most once per minute to keep the Map from growing unbounded.
The limits live in src/proxy.ts and are checked before any auth or session work — so a rejection never pays Auth0 latency. Four tiers: /api/vitals POST capped at 20 per minute (open ingestion, tightest limit), /api/geo GET at 30 (also unauthenticated, and cached server-side for the same window anyway), /api/graphql POST at 60 per minute (public proxy), and all other API routes at 300 per minute as a backstop. Blocked requests get a 429 with Retry-After and X-RateLimit-* headers.
The fix is a unified helper: src/lib/parseBody.ts wraps the read, size check, and Zod parse into one call. It runs two size checks: first the Content-Length header (fast, before the body is buffered at all), then a byte count on the parsed JSON string (catches chunked transfers that skip the header). A failure at either point returns a 413 immediately.
All ten calendar routes now use parseBody() instead of the old inline three-line pattern, so they get size enforcement and Zod validation in one call. The two open routes use inline guards with tighter limits: /api/vitals at 4 KB (it only needs five fields) and /api/graphql at 64 KB (GraphQL queries can be verbose). Authenticated routes default to 64 KB.
On Vercel each edge function instance has its own memory, so the counter isn't globally coordinated across instances. For a personal portfolio with low traffic, a per-instance limit is still effective. A determined attacker with many IPs would bypass any client-side rate limit anyway — the real defense there is the backend. This is about stopping accidental loops and casual spam, which in-memory handles perfectly.
That last sentence was leaning on something I had never checked. The API's limiter was in-memory too, on a host that scales to zero, so every cold start wiped every counter — the backstop this paragraph points at was thinner than the thing it was excusing. It is a shared store now, so the claim is finally true, and two more bugs turned up on the way there: a config option silently dropped by a major version, and one store instance shared across every limiter so hitting one endpoint spent another's budget.
Both are the same shape as most of what a later security audit found — a protection that looks configured and is not. That audit, and the seven shapes it turned up, is written up at Auditing for Absences.
Swapping the store for Vercel KV or Upstash Redis would give cross-instance coordination if traffic ever justifies it. The interface is simple enough that the swap is a one-file change.
One line above deserved more scrutiny than it got: the real defense there is the backend. That was leaning on something I had not checked. The API’s limiter was in-memory too, and it runs on Fly with min_machines_running = 0, so every cold start wiped every counter — pacing requests around scale-to-zero got you an effectively unlimited quota. The backstop this page pointed at was thinner than the thing it was excusing.
It is a shared Redis store now, so the sentence is finally true. Two things fell out of doing it. The version bump revealed that express-rate-limit renamed max to limit in v7 and dropped the alias in v8 — every limiter had been silently falling back to the library default of five rather than its configured ceiling. And I gave every limiter one shared store instance, which the library rejects outright: keys come from the caller rather than the route, so a shared backend counts one visitor into one bucket and hitting one endpoint spends another’s budget. Both were the same shape of mistake — a limit that looks configured and is not.
Three routes were forwarding user-controlled strings into backend URLs and fetch calls without any format check. Dynamic route params and query strings are just as much of a trust boundary as request bodies — they just happen to arrive in a different part of the request.
The season segment in /api/nba/league/[season] gets dropped directly into an ESPN API URL — a four-digit year check with /^\d{4}$/ covers the entire valid range and nothing else. The origin param in /api/google/auth/url gets embedded in the OAuth state and used as a redirect target after the flow completes — it must be a valid URL with an http(s) protocol to block open-redirect payloads. The cursor param in the countdowns list is capped at 512 characters and restricted to base64/alphanumeric characters — enough to cover any real cursor token while ruling out injected content.
Several routes were returning 200 with empty data on failures instead of a real error status. The vitals /by-version and /versions routes returned { byVersion: [] } / { versions: [] } on any backend error. The TCG cards, series, and sets routes had no error handling at all — the SDK could throw or return null and the response would be an empty list with a 200.
Every route now returns { error: string } with an appropriate HTTP status on any failure — 502 for backend unavailability, forwarded status codes for backend errors. A 200 always means success with real data. The client-side React Query hooks already checked if (!res.ok) throw, so they surface errors correctly now instead of rendering empty state that looks like no results.