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. Three tiers: /api/vitals POST capped at 20 per minute (open ingestion, tightest limit), /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.
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.
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.