A studio is the tenant. Every domain row belongs to one studio, directly through a studioId column or through a parent row that has one. There is no row level security in the database. Isolation is enforced in code, so the rules below are not optional.

Rule 1: the studio comes from the principal

The studio id is never read from the request body, the query string or, with one exception, the path. It comes from whatever the lane’s guard attached to the request. The exception is the WhatsApp webhook, POST /v1/whatsapp/webhook/:studioId. SmartSend calls a URL the API handed it at connect time, so the studio is in the path and the request is authenticated by the shared token. studioIdOf throws UNAUTHORIZED when req.auth is missing. It is defined at the top of each controller:

Rule 2: services take studioId first

Every service function that touches tenant data has studioId as its first parameter and passes it to the repository. This makes a missing scope visible in review: a repository call without a studio argument stands out.

Rule 3: repositories filter by studio

Single row reads

Use findFirst with both keys. findUnique({ where: { id } }) skips the tenant check.

Updates and deletes by id

Prisma’s update and delete need a unique where, which is usually { id } alone. The convention is: the service loads the row through the scoped read first, throws NOT_FOUND if it is missing, then calls the unscoped write.
When a check and write must be atomic, use updateMany or deleteMany with { id, studioId } in the where and check the count.

Foreign keys in the body

An id the client sends in a body is untrusted. Before linking it, confirm it belongs to the same studio. products.service.ts does this for forms:
Do the same for clientId, coachId, formTemplateId, programId and any other reference.

Answer NOT_FOUND, not FORBIDDEN

A row in another studio is reported the same way as a row that does not exist. requireAssignedClient follows the same rule for a coach restricted to their own trainees. A 403 would confirm the id is real.

The two studio ids

A studio has two identifiers and mixing them up is the most common tenancy bug. webUserContext is the place the first is turned into the second. Code inside /v1/web handlers always sees Studio.id. Code in @repo/* packages works with organization ids and reaches domain rows through studio: { externalOrgId } relations, as claimStaffSeats does. A user has two ids in the same way: the better-auth user.id is stored on the coach row as Coach.externalUserId. req.auth.userId is the auth user id. To get the Coach.id, look the coach up by (studioId, externalUserId), which is what resolveCoachAccess does.

Archived studios

Deleting an organization archives its studio instead of removing it. Studio.deletedAt is set, externalOrgId is released and the slug is parked under a suffix (archiveStudioForOrganization in @repo/auth). The rest of the system has to treat an archived studio as gone:
  • authenticateTrainee rejects trainee tokens for it.
  • requirePartnerKey rejects its API keys even before they are revoked.
  • sendTraineeNotification filters push tokens by client.studio.deletedAt: null.
  • Schedulers add studio: { deletedAt: null } to their candidate queries.
When you write a query that fans out across studios (a scheduler, a report), add that filter.

Trainees and soft deletes

Client.deletedAt marks a deleted trainee. Trainee facing reads and push sends filter on deletedAt: null. requireTraineeAppAccess answers UNAUTHORIZED for a deleted client. One person can be a trainee in more than one studio. Each studio has its own Client row, matched by phone. The trainee token carries one clientId and one studioId, and POST /v1/trainee/auth/switch-studio issues a new token for another row with the same phone number. A trainee request must never read by phone alone. It reads by the clientId in the token.

Data shared across studios on purpose

When you add a shared table, make the read helper the only way to query it, so the “global or mine” condition is written once.

Background jobs

Workers have no request and no principal. A job either carries the ids it needs in its payload (runId, importId, hookId) and loads the studio from the row, or it scans all studios and processes each in a loop. In both cases the per studio functions still take studioId explicitly. Do not write a job that loads a client by id and then acts without checking the row’s studioId against the job’s context.

Testing tenancy

For each module, a test should show that a request scoped to studio A cannot read, update or delete a row of studio B, and that a body carrying a foreign id for a linked entity is refused.