The rules behind the steps
Ownership is checked on EVERY verb
Ownership is checked on EVERY verb
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.Routes never throw
Routes never throw
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.Never return a raw Prisma object
Never return a raw Prisma object
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.Business logic lives in services
Business logic lives in services
A route handler that contains a decision has put business logic in the transport
layer, where it cannot be unit-tested or reused.
Audit every state change
Audit every state change
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
EveryfindMany in an API route must carry take: or cursor:. CI enforces it.
- List endpoints clamp a
limitquery parameter — default 50–100, maximum 200–1000 depending on the list — and return apagination: { total, limit, offset, hasMore }block. - Queries that are bounded by their nature still take an explicit
takecap, 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.
Legal entity on writes
Reads may use the ambientgetCurrentLegalEntityId(). 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:grepfor 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.- 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
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.