Skip to content

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/json for all request/response bodies
  • Authentication: Clerk session token in Authorization: Bearer <token> header
  • Organization context: Set by the frontend proxy via the X-Organization-ID header (resolved from the request hostname). Falls back to the user's current_organization_id column, then their first membership, then uuid.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

json
{
  "data": {
    "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001",
    "name": "Initial Consultation",
    "created_at": "2026-04-26T10:30:00Z"
  }
}

Error

json
{
  "error": {
    "code": "organization_not_found",
    "message": "Organization not found"
  }
}

Validation Error

json
{
  "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:

PrimitiveTypeWhen to use
is_superadminboolPlatform operator check (Console access, RLS bypass). Source of truth: platform_roles contains "superadmin".
current_role_codestringDisplay only ("Specialist", "Admin"). Per-org and customizable — never branch authorization on this.
current_permissionsstring[]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_orgboolTrue iff the principal holds an organization_memberships row for the current org. Gates Clinic dashboard layout.
is_patient_at_current_orgboolTrue 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

json
{
  "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_id is the org the request is scoped to (header → user column → first membership → null).
  • current_role_code / current_permissions are computed for that org. For superadmins operating without a tenant scope, the role field is an empty string and current_permissions is [] — gate Console/superadmin UI on is_superadmin instead.
  • last_activity is null until the activity bumper records the user's first request.
  • platform_roles is the source of truth for is_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:

json
{
  "organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100"
}

Response: 200

json
{
  "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_errororganization_id is missing or 00000000-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_id so subsequent requests without an explicit X-Organization-ID header 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/me to 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:

  1. The middleware verifies the JWT against Clerk.
  2. It fetches the user's Clerk profile (primary email) and inserts a row in users with ON CONFLICT DO NOTHING (concurrent-first-request safe).
  3. It writes an audit_log row for the create — best-effort, the request still proceeds if the audit insert fails.
  4. If the request carries X-Organization-ID, the middleware also auto-links the new user as patient in 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 sets X-Organization-ID from the resolved hostname. The patient role 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-org default_signup_role_id knob — see the TODO in services/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.

CodeHTTPDescription
unauthorized401Missing or invalid Bearer token
forbidden403Caller lacks the required permission, or attempted to switch into a non-member org
validation_error400Request body shape was valid but a field is required/invalid
invalid_body400Request body was not valid JSON
organization_not_found404Organization does not exist
internal_error500Unexpected server error (generic; never leaks internals)