Authentication API Endpoints
API contract for the auth-related endpoints shipped today. Source of truth: services/api/internal/core/domain/user/handler.go and the JWT middleware in services/api/internal/core/middleware/auth.go.
API Conventions
- Base path:
/v1/(versioned from day one) - Content-Type:
application/jsonfor all request/response bodies - Authentication: Clerk session token in
Authorization: Bearer <token>header - Organization context: Set by the frontend proxy via the
X-Organization-IDheader (resolved from the request hostname). Falls back to the user'scurrent_organization_idcolumn, then their first membership, thenuuid.Nil(no org scope). - IDs: Every resource ID is a UUIDv7 rendered as the canonical 36-char hyphenated form. There are no integer IDs anywhere on the wire.
- Errors:
{ "error": { "code": "...", "message": "..." } }
Standard Response Formats
Single Resource
{
"data": {
"id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001",
"name": "Initial Consultation",
"created_at": "2026-04-26T10:30:00Z"
}
}Error
{
"error": {
"code": "organization_not_found",
"message": "Organization not found"
}
}Validation Error
{
"error": {
"code": "validation_error",
"message": "Validation failed",
"fields": {
"email": "must be a valid email address",
"name": "is required"
}
}
}fields is per-input-name and only present on validation errors.
RBAC Primitives
The Core API does not expose a single global role. Tenant authorization is per-org permission codes; the /v1/me envelope surfaces three related primitives the frontend may inspect:
| Primitive | Type | When to use |
|---|---|---|
is_superadmin | bool | Platform operator check (Console access, RLS bypass). Source of truth: platform_roles contains "superadmin". |
current_role_code | string | Display only ("Specialist", "Admin"). Per-org and customizable — never branch authorization on this. |
current_permissions | string[] | Decide which UI affordances to render. Capability permissions (organizations.update, organizations.view_directory, audit_log.view_org, patients.view, …). The authoritative gate is also enforced server-side by RequirePermission middleware + RLS. |
is_staff_at_current_org | bool | True iff the principal holds an organization_memberships row for the current org. Gates Clinic dashboard layout. |
is_patient_at_current_org | bool | True iff the principal holds a patients row for the current org (directly or via a caregiver link). Gates Patient Portal layout. |
App access is structural, not permission-based (post-1.26). The Clinic app entry condition is the existence of an organization_memberships row at the current org; the Patient Portal entry condition is the existence of a patients row at the current org. There is no patient system role and no app.access_* permissions. Custom per-org roles (e.g. a clinic admin invents head_specialist) still work end-to-end: the role is staff-side by virtue of being attached via organization_memberships, and the role's permissions are picked from the catalog by the admin who creates it. See decisions.md → Why patients are not memberships.
Endpoints
GET /v1/me
Returns the current authenticated user's profile, memberships, and permissions for the current org.
Authentication: Required — Clerk session token.
Request: No body.
Response: 200
{
"data": {
"id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001",
"email": "[email protected]",
"is_superadmin": false,
"platform_roles": [],
"confirmed": true,
"last_activity": "2026-04-26T14:22:00Z",
"current_organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"memberships": [
{
"organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"role_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f200",
"role_code": "specialist"
}
],
"current_role_code": "specialist",
"current_permissions": [
"organizations.view_directory",
"patients.view"
],
"is_staff_at_current_org": true,
"is_patient_at_current_org": false
}
}Notes:
current_organization_idis the org the request is scoped to (header → user column → first membership →null).current_role_code/current_permissionsare computed for that org. For superadmins operating without a tenant scope, the role field is an empty string andcurrent_permissionsis[]— gate Console/superadmin UI onis_superadmininstead.last_activityisnulluntil the activity bumper records the user's first request.platform_rolesis the source of truth foris_superadmin; today the only seeded value is"superadmin", but the array is open-ended.
PUT /v1/me/switch-organization
Switch the user's active organization context.
Authentication: Required — Clerk session token.
Request:
{
"organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100"
}Response: 200
{
"data": {
"current_organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"message": "Organization switched successfully"
}
}Errors:
400 invalid_body— request body is not valid JSON.400 validation_error—organization_idis missing or00000000-0000-0000-0000-000000000000.403 forbidden— caller is not a member of the target org (superadmins are exempt).
Notes:
- The endpoint persists the choice on
users.current_organization_idso subsequent requests without an explicitX-Organization-IDheader default to the new org. - The audit row is scoped to the destination org so the destination org's admin sees the user's arrival.
- Switching does not change permissions in the current request — frontends should re-fetch
/v1/meto refresh the permission set.
First-Login Provisioning
There is no Clerk webhook. New users are provisioned just-in-time inside the ClerkAuth middleware (services/api/internal/core/middleware/auth.go). On the first request bearing a Clerk JWT for an unknown subject:
- The middleware verifies the JWT against Clerk.
- It fetches the user's Clerk profile (primary email) and inserts a row in
userswithON CONFLICT DO NOTHING(concurrent-first-request safe). - It writes an
audit_logrow for the create — best-effort, the request still proceeds if the audit insert fails. - If the request carries
X-Organization-ID, the middleware also auto-links the new user aspatientin that org (idempotent) and audits the membership create. This is what makes patient sign-up on the Patient Portal a one-shot flow — the portal proxy always setsX-Organization-IDfrom the resolved hostname. Thepatientrole code is the only tenant role string Go code references; it expresses the structural product invariant "self-signup goes to the seeded patient role". When org-level settings ship (alongside plans), this becomes a per-orgdefault_signup_role_idknob — see the TODO inservices/api/internal/core/middleware/auth.go.
The Clinic and Console apps never set X-Organization-ID for unprovisioned users (Clinic sign-up is disabled; Console is superadmin-only), so the auto-link only fires for portal signups.
Error Code Reference
Auth-relevant subset; the full list of error codes is in apps/docs/reference/error-envelope.md.
| Code | HTTP | Description |
|---|---|---|
unauthorized | 401 | Missing or invalid Bearer token |
forbidden | 403 | Caller lacks the required permission, or attempted to switch into a non-member org |
validation_error | 400 | Request body shape was valid but a field is required/invalid |
invalid_body | 400 | Request body was not valid JSON |
organization_not_found | 404 | Organization does not exist |
internal_error | 500 | Unexpected server error (generic; never leaks internals) |