src/http/respond.ts, packages/errors, packages/types, packages/security, packages/observability and src/middleware/rate-limit.ts.
Response envelope
Controllers answer through three helpers insrc/http/respond.ts.
errorMiddleware in packages/errors:
details is present only when the error carries one. requestId is omitted when the request has none, which does not happen behind requestIdMiddleware.
Surfaces with a different shape
Error codes
ErrorCode is defined in packages/types/src/index.ts. AppError maps each code to a default status in packages/errors/src/index.ts. A throw site can override the status with options.status.
Errors that are mapped for you
normalize in packages/errors turns a few non-AppError failures into codes:
Internal error text never reaches the client for unmapped failures. The middleware logs 5xx with
[error] and 4xx with [client-error], both with the request id, path, method, code and message.
Reasons in details
Some errors carry a machine-readable details.reason that clients branch on:
The meal analyzer uses
details.easterEgg (alcohol or cannabis) for rejected photos. See Trainee nutrition.
Validation
Controllers parsereq.params, req.query and req.body with a Zod schema from the module’s *.schema.ts, then call the service with typed values. A thrown ZodError becomes:
- Numbers in query strings and many bodies use
z.coerce.number(), so"72.5"is accepted where a number is expected. - Dates use
z.coerce.date()for instants and aYYYY-MM-DDregex orz.iso.date()for calendar days. - Schemas are plain
z.object, so unknown keys are stripped, not rejected. - Trainee form answers are validated by the form engine, not Zod. A failed answer returns 400
BAD_REQUESTwith a Hebrew message naming the field and adetailsarray of{ key, label, message }. See Trainee forms. - The automation lane coerces much harder (empty strings mean “not provided”,
"true"is a boolean) and reports validation as 400 with one readable sentence. See Automation API.
Pagination and sorting
There is no single pagination scheme. Three patterns exist.
The
pageSize maximum is set per schema. It is 500 on the web lists read for this reference (clients, forms, content) and 100 on the partner workouts list. The trainee content list accepts page and pageSize (default 100, max 500) and returns { items, total } without echoing the page.
Fixed caps on the trainee lane: workout history returns the latest 100 completed logs, meal history the latest 50, progress photos the latest 12, the measurements log 120 rows, cardio history at most 90 days, and the shopping food list 500 rows.
No endpoint in these lanes takes a sort parameter. Order is fixed in each repository query and stated on the endpoint.
Dates and time zones
- Instants are
DateTimecolumns and serialize as ISO 8601 UTC strings, for example2026-10-07T06:15:00.000Z. - Calendar days are
YYYY-MM-DDstrings in requests and in most computed responses. Columns typed@db.Date(DailyMetric.date,CardioLog.performedOn,NutritionDayLog.date) store the day as UTC midnight of that day key. When a raw Prisma row is returned, such a column serializes as2026-10-07T00:00:00.000Z. - “Today” for a trainee is computed in the trainee’s time zone.
resolveTimeZoneinsrc/lib/timezone.tstakes the first valid value ofClient.timezone, thenStudio.timezone, then the defaultAsia/Jerusalem. Client.timezoneis written by the trainee lane itself. Every authenticated trainee request with a valid IANA zone inx-timezoneupdates it, with a 12 hour in-memory cache per client. Preview tokens never write it.tzDayWindow(iso, timeZone)gives the UTC instants that bound a local day. Instant columns such asMealLog.consumedAtandWorkoutLog.performedOnare filtered with that window. Date columns are matched by day key.- The trainee calendar is the exception. Slots are generated in the studio’s time zone only.
- Weeks in the trainee app start on Sunday.
currentWeekIsoDatesbuilds the seven day keys from the Sunday on or before today. - Money is stored in agorot as integers, for example
priceAgorot.
Ids
- Database ids are Prisma
cuid()strings. Treat them as opaque. - Request ids are ULIDs from
ulidx. - Studio API keys are
pf_live_plus 40 hex characters. Webhook signing secrets are 48 hex characters. - A few ids are composite strings built at read time and never stored as rows: activity feed ids such as
workout:<id>,form:<id>andvideo:<id>, and nutrition swap option ids that start withauto:. - Ids inside program content (days, rows, meals, items) live in the program JSON, not in their own tables. A trainee write that names one, such as an exercise substitution, is checked against the current content.
- Trainees created through the automation lane can carry a caller-supplied
externalId. It is stored onClient.externalUserIdasautomation:<studioId>:<externalId>and returned without the prefix. - Tenancy is never taken from the path or body. The studio always comes from the authenticated principal.
Rate limits
Global limiters
Both areexpress-rate-limit instances created in src/middleware/rate-limit.ts and wired in src/app.ts. They send the standard RateLimit-* headers and no legacy X-RateLimit-* headers.
Details that matter in practice:
/v1and/mcpshare one limiter instance, so a caller’s requests to both count in the same bucket.- Requests to
/v1/public/sign/*that carryx-service-idskip the global limiter. The signing lane counts them itself. /api/*,/healthz,/readyzand/metricsare not behind either limiter.- When a global limiter trips, the response is the library’s default 429 body, not the JSON error envelope. Clients should branch on the status code.
trust proxyis set to exactly1, so the key is the address forwarded by the single reverse proxy. A client cannot pick its own bucket by forgingX-Forwarded-For.
Service-level throttles
These are counted in Redis and answer with the envelope and codeRATE_LIMITED unless noted.
Other fixed limits found in code:
The signing throttle fails open: if Redis is unreachable the request is allowed.
Request ids
requestIdMiddleware in packages/observability runs on every request. If the caller sends a non-empty x-request-id header it is reused. Otherwise a ULID is generated. The id is stored on req.requestId, returned in the X-Request-Id response header, and included as requestId in every envelope. It is also on the error log line, so a support report with a request id can be traced.
Replay protection
Two mechanisms use the same Redis nonce store (createRedisNonceStore, key perform:nonce:<nonce>, SET NX):
- Service signatures.
requireServiceAuthrejects a timestamp more than 300 seconds from server time and consumes the nonce with a 360 second TTL. A replayed signed request fails with 401nonce already used. This covers the web gateway, internal and admin lanes, and signed calls to the public signing lane. replayProtection. A standalone middleware that requiresx-nonceandx-timestampand applies the same window and nonce check without any signature. It is mounted once, in front of the bearer lane only.
requestId that makes a retry a no-op, and automation trainee creation accepts an externalId. Both are documented on their pages.
HTTP methods
GETreads.POSTcreates or performs an action.PATCHupdates part of a resource.PUTreplaces a whole document, such as the nutrition day snapshot.DELETEremoves.- A create usually returns 201. The automation lane always returns 200.
- A delete does not always return 204. Many trainee deletes return 200 with the deleted id so the app can update its cache. Each endpoint states its status.