Skip to main content
Every API route follows the same skeleton. Not “usually” — every one.

The rules behind the steps

Checking ownership on PATCH but not on GET is an IDOR vulnerability, and it is the single most repeated review finding on this codebase. When you touch one handler in a file, check the others in the same file.
Services may throw. Routes catch and return { success: false, error }. An uncaught throw in a route is a stack trace where a JSON error belongs.
Project to a DTO. An over-broad select that leaks a token, a password hash or an unrelated column is a data breach with a 200 status code.
A route handler that contains a decision has put business logic in the transport layer, where it cannot be unit-tested or reused.
Every POST, PATCH, PUT and DELETE — including error paths and fallback redirects. CI fails a state-changing route with no audit call in its import graph and no justified allowlist entry.

Bounded queries

Every findMany in an API route must carry take: or cursor:. CI enforces it.
  • List endpoints clamp a limit query parameter — default 50–100, maximum 200–1000 depending on the list — and return a pagination: { total, limit, offset, hasMore } block.
  • Queries that are bounded by their nature still take an explicit take cap, with a comment saying why that number.

Scoped reads

Where a permission controls breadth rather than access — applications:view-all, staff-absence:view-all, salaries:view-all — resolve the scope centrally rather than re-deriving it per route. The manager graph and the position scope both have shared resolvers; use them, so a scoping fix lands everywhere at once. Reads may use the ambient getCurrentLegalEntityId(). Writes may not. Every write persists an explicit, tenant-validated legalEntityId from resolveWriteLegalEntity. The ambient resolver is on a path to becoming cookie-driven, and a user-controlled cookie must never decide who legally employs a person.

The sibling-path rule

The second most repeated review finding is “the fix is correct but incomplete — the same bug still lives in a parallel code path”. Before pushing:
  1. grep for every sibling call site of what you changed — both handlers on a route, every resolver in a family, every caller of the service you patched.
  2. Where two functions must stay in sync by construction, pin them with a same-shape test so divergence fails CI rather than surviving to the next refactor.

Error handling

Empty catch blocks are an ESLint error. A catch block must log and do one of: re-throw, return an error response, or set UI error state. console.error followed by carrying on is not handling. JSON.parse() is never called without a try/catch — malformed JSON from the database crashes the process.