Success helpers

apps/core-api/src/http/respond.ts exports three functions. Controllers use nothing else to write a success response.
ok writes the envelope. requestId is included when the request id middleware ran, which is always the case for routed requests.
The types live in @perform/types:

Pagination

List endpoints take page and pageSize from the query schema and return the Paginated shape inside data:
The default pageSize is usually 20. The maximum is set per module in its Zod schema and is not uniform. productListQuery allows up to 500. Read the schema instead of assuming 100.

AppError

@perform/errors exports the one error class services throw.
status defaults from the code. Pass options.status only when a code needs a different status.

Codes

ErrorCode is an as const object in @perform/types, re-exported by @perform/errors.

details

details is free form and reaches the client unchanged. The codebase uses a reason string inside it as a machine readable sub code, so clients can branch without parsing messages: The trainee app logs the user out on any 401. That is why a wrong OTP is BAD_REQUEST and a frozen subscription is FORBIDDEN with a reason. Do not answer 401 for anything other than a session that is really gone.

The error middleware

errorMiddleware in @perform/errors is registered last in app.ts. It normalizes whatever was thrown, in this order:
1

AppError

Used as is.
2

Prisma errors

mapPrismaError recognizes Prisma errors by err.name, not by instanceof, so it works across the two Prisma client packages.
3

Body parser errors

mapBodyParserError handles errors from express.json and express.raw (objects with type, expose: true and a 4xx status). The result is BAD_REQUEST with the parser’s own status: payload too large for 413, invalid request body otherwise.
4

ZodError

Status 422, code VALIDATION, message validation failed, and details as a list of { path, message } taken from err.issues.
5

Anything else

Status 500, code INTERNAL, message internal server error. The original message is never sent to the client.
The Prisma mapping is a safety net. Messages from it are generic. A service that expects a unique constraint to fire should catch it and throw an AppError with a useful message.

What is logged

The middleware logs every error with console, not pino:
  • 5xx: console.error('[error]', { requestId, path, method, code, name, message }).
  • 4xx: console.warn('[client-error]', { requestId, path, method, status, code, message }).
The 4xx line exists because the reason for a rejected request otherwise lives only in the response body. The comment in the code describes a trainee retrying a rejected form submit four times and leaving four bare statusCode: 400 lines. The stack is not logged by the middleware. If you need a stack for a failure, log it where it happens with ctx.logger.error({ err }, 'message'). pino serializes err with its stack.

Not found

notFoundMiddleware answers any unmatched route with 404, code NOT_FOUND, message route not found: METHOD /path. Under /v1 an unknown path usually hits replayProtection first. See the warning on Route lanes.

Logging errors outside a request

Two helpers exist and they belong to different package families. serializeError returns { name, message, code?, stack? } with the stack cut to four frames. logError logs { error: serializeError(error), ...context }. Nothing under apps/core-api/src calls logError. Use the pino logger from the context there.

Never swallow, but know the best effort pattern

A handler must not hide a failure of its main job. Side effects that must not fail the main job are a different matter, and the code has a consistent shape for them: do the main write, then run the side effect in a try/catch or a .catch that logs and continues. Examples: registerPushToken saves the token and then tries to send pending form pushes. updateFoodPreferences saves and then tries to write a timeline entry. sendTraineeNotification catches everything and returns an empty result, because a push failure must not fail the coach’s action. When you add one, log it with enough context to find it later, and never use it around the main write.

Lanes with their own shape

Automation error mapping

automationErrorMiddleware is registered on the automation router, so the global middleware never sees these errors.
  • ZodError becomes 400 with one readable line: Invalid request: phone is required; .... At most five issues are listed, then (and N more).
  • AppError uses a narrower status map: UNAUTHORIZED 401, FORBIDDEN 403, CONFLICT 409, RATE_LIMITED 429, SERVICE_UNAVAILABLE 503, INTERNAL 500. Every other code, including NOT_FOUND and VALIDATION, becomes 400, because Make surfaces a 400 with the reason best.
  • Anything else is 500 with the error’s own message, logged with console.error('[automation-api]', err).
On this lane a non AppError failure sends err.message to the caller. Internal messages can leak that way. Throw AppError with a deliberate message from automation services.