studios module exposes the caller’s own studio. There is no list or create endpoint: a Studio row is created by the web gateway middleware on first contact (ensureStudio in middleware/web-context.ts) or by the internal provisioning route, both keyed on the better-auth organization id in Studio.externalOrgId.
API keys for the studio are managed by a separate router mounted under the same prefix. See Studio API keys.
Mount points and auth
studiosRouter is mounted twice in modules/index.ts.
Source:
apps/core-api/src/modules/studios/.
Role rules come from requireRole in the routes file:
On the web lane the role is whatever the gateway sends in
x-user-role (unknown values fall back to SUB_COACH). On the bearer lane session-verifier.ts maps the better-auth member role: owner to OWNER, admin to HEAD_COACH, anything else to SUB_COACH.
/v1/web/studios/current/api-keys is registered before /v1/web/studios, so those paths are handled by the API keys router.
The studio object
All three endpoints return theStudio Prisma row.
string
cuid.
string | null
The better-auth organization id. Unique.
string
Studio name.
string
Unique slug. Not editable through this module.
string
Default
he.string
Default
Asia/Jerusalem.object | null
Free-form JSON. Two keys carry rules, see below.
object | null
Free-form JSON. Known keys are listed below.
string
ISO timestamp.
string
ISO timestamp.
string | null
Set when the studio is archived.
Endpoints
Paths below use the web lane prefix. The bearer lane serves the same handlers under/v1/studios.
GET /v1/web/studios/current
Returns the caller’s studio.
Auth: web lane or bearer lane, any role.
Response:
NOT_FOUND (404) studio not found, UNAUTHORIZED (401).
PATCH /v1/web/studios/current
Updates top-level studio fields. branding and settings are replaced whole when sent, with the two exceptions described under Behaviour.
Auth: web lane or bearer lane, roles OWNER or HEAD_COACH.
string
At least 1 character.
string
At least 2 characters. Not checked against a list of supported locales.
string
At least 1 character. Not checked as a valid IANA zone here.
object
Any JSON object. Replaces the stored blob.
object
Any JSON object. Replaces the stored blob, except that
mbp* keys in the body are ignored.- Branding.
normalizeBrandingInputinbranding.tsvalidates two keys when they are present and passes everything else through untouched.instagramHandle: trimmed, leading@removed, lowercased. Must match^[a-z0-9._]{1,30}$. Blank ornullclears it. A handle stored before this rule existed is kept as stored if it comes back unchanged, so it never blocks saving other fields.storyProfileImage: must be anhttps:URL of at most 2000 characters. Blank ornullclears it.
- Settings.
withStoredMbpSettingsdrops every key starting withmbpfrom the body and carries over the storedmbp*keys. Those keys change only throughPATCH /current/settings, because changing them re-prices the food library.
studio.update and takes no row lock. Use the settings endpoint below for single-key changes to avoid overwriting a concurrent save.
Response: the updated studio object.
Errors:
VALIDATION(422)invalid instagram handle...withdetails[{ "path": ["branding", "instagramHandle"], "message": "INSTAGRAM_HANDLE_INVALID" }].VALIDATION(422)invalid story profile image urlwithdetailsmessageSTORY_PROFILE_IMAGE_INVALID.VALIDATION(422) from Zod.FORBIDDEN(403)insufficient role.NOT_FOUND(404).
PATCH /v1/web/studios/current/settings
Merges the given keys into Studio.settings under a row lock, and re-prices the food library when an MBP anchor changes.
Auth: web lane or bearer lane, roles OWNER or HEAD_COACH.
boolean
Turns trainee meeting booking on or off. Readers treat any value other than
false as enabled. See Calendar.boolean
Turns the MBP portion system on. Readers require exactly
true.number
Kcal per protein portion, 20 to 2000. Default when unset is 180.
number
Kcal per carb portion, 20 to 2000. Default when unset is 160.
number
Kcal per fat portion, 20 to 2000. Default when unset is 180.
string | null
The portion unit tag shown to coaches and trainees, at most 20 characters after trimming. Blank or
null stores null, which means the per-locale default.string | null
The studio’s contact number for the trainee app, at most 32 characters. Stored trimmed as typed. It must contain 8 to 15 digits. Blank or
null clears it.boolean
Whether the trainee app shows the contact number.
string
ASSIGNMENT, INTAKE_FORM, FIRST_PLAN or FIRST_WORKOUT. Under any mode other than ASSIGNMENT, a subscription assigned without a date waits for the named event. An absent or unknown value reads as ASSIGNMENT.z.object, so keys outside this list are stripped and cannot be written here.
Behaviour: everything runs in one transaction from foods.runInTransaction.
lockStudioSettingsrunsSELECT id FROM studios WHERE id = ... FOR UPDATEand reads the currentsettings.- The patch is merged over the current blob (
{ ...current, ...patch }) and saved. Keys not in the patch are kept, so concurrent saves of different keys do not lose each other. readMbpAnchorsis read from the old and the new blob. If the three anchors are the same, the response carriesfoodRepricing: null.- If an anchor changed,
repriceStudioFoodsruns in the same transaction with modechange. It loads every food the studio sees (system foods, the studio’s overrides and its own foods), plans the new portion values, applies them, and writes anAuditLogrow withactionmbp.anchors.reprice,resourceTypeSTUDIO_SETTINGS,resourceIdset to the studio id,actorIdset toreq.auth.userIdand adiffholdingmode,from,toand the report.
foodRepricing.
object | null
null when no anchor changed. Otherwise the RepriceReport from modules/foods/reprice.ts.VALIDATION(422)invalid whatsapp number.VALIDATION(422) from Zod, for example an anchor outside 20 to 2000.FORBIDDEN(403)insufficient role.NOT_FOUND(404)studio not found.
Other keys in settings
Studio.settings is shared by several modules. Keys that this module does not validate but that other code reads include:
Because
PATCH /current replaces the whole blob, a client that sends settings must send back every non-mbp key it wants to keep. Dropping onboarding or agent this way changes how billing limits and the agent behave.
Branding for the trainee app
projectBranding in branding.ts shapes the blob the trainee app receives (called from modules/trainee/trainee.service.ts). Everything passes through as stored, except that instagramHandle is shown in canonical form: no @, lowercase, or null when blank.