openapi: 3.0.3
info:
  title: Easerix Auth API
  description: |
    Platform authentication service (backend/services/platform/user-authentication).
    Bearer JWT (HS256, 1h) + opaque refresh tokens (SHA-256-hashed at rest, 30d).
    Magic links are single-use and expire after 15 minutes. Email
    confirmation links are single-use and expire after 24 hours.
    Authored from the shipping Gin handlers — this spec records reality.
  version: 1.0.0
  contact:
    name: Perizer Labs
    url: https://easerix.com
servers:
  - url: https://auth.easerix.com
    description: Production
tags:
  - name: system
    description: Health and liveness
  - name: auth
    description: Sessions, magic links, and identity
  - name: me
    description: Profile, password, sessions, grants, and user API keys
  - name: orgs
    description: Organizations
  - name: members
    description: Org membership and roles
  - name: invitations
    description: Inviting people
  - name: teams
    description: Teams within an org
  - name: tools
    description: Per-org tool access
  - name: api-keys
    description: Org API keys
  - name: audit
    description: Org audit log
  - name: domains
    description: Domain claims and discovery

paths:
  /health:
    get:
      operationId: health
      tags: [system]
      description: Unauthenticated liveness check used by the platform and monitors.
      summary: Liveness probe
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object

  /v1/auth/register:
    post:
      operationId: register
      tags: [auth]
      description: >-
        Creates an account and immediately issues a session (access JWT +
        opaque refresh token). The account starts with email_verified: false
        and a confirmation link is emailed (best-effort — a mail hiccup never
        blocks signup); domain-based org auto-join/discovery is withheld
        until that link is used, since an unverified address is only a claim.
      summary: Create an account with email + password
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 8 }
                name: { type: string }
      responses:
        "200":
          description: Account created; session issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: An account with that email already exists
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/auth/login:
    post:
      operationId: login
      tags: [auth]
      description: Password sign-in. Issues a session on success; 401 on bad credentials without distinguishing which factor failed.
      summary: Sign in with email + password
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
      responses:
        "200":
          description: Session issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid email or password
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/auth/magic-link:
    post:
      operationId: requestMagicLink
      tags: [auth]
      description: Creates a single-use, 15-minute sign-in link and emails it when SMTP is configured. Always returns 200-shaped success for valid bodies.
      summary: Email a single-use sign-in link (15-minute TTL)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                client:
                  type: string
                  enum: [web, mobile]
                  description: >-
                    Where the emailed link should land. "mobile" points it at a
                    domain the app claims as a universal/app link so the tap
                    opens the app; the web page at the same URL is the fallback
                    when the app isn't installed. Anything else (or omitted)
                    uses the web base.
      responses:
        "200":
          description: Link created (and emailed when SMTP is configured)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                additionalProperties: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/auth/magic-link/verify:
    post:
      operationId: verifyMagicLink
      tags: [auth]
      description: Exchanges an emailed magic-link token for a session. Tokens are single-use; a second exchange returns 401.
      summary: Exchange a magic-link token for a session
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200":
          description: Session issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Link is invalid or has expired
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/auth/verify-email:
    post:
      operationId: verifyEmail
      tags: [auth]
      description: >-
        Confirms a password account's email address using the single-use,
        24-hour token from the confirmation email. Once confirmed, domain
        auto-join/discovery (see /v1/me/discoverable) is (re-)evaluated for
        the account. Tokens are single-use; a second exchange returns 401.
      summary: Confirm a password account's email address
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200":
          description: Email confirmed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  user: { $ref: "#/components/schemas/User" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Link is invalid or has expired
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/auth/refresh:
    post:
      operationId: refresh
      tags: [auth]
      description: "Rotates the opaque refresh token: the presented token is revoked and a new pair is issued."
      summary: Rotate a refresh token into a new session
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        "200":
          description: New session issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid or expired session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/auth/token:
    post:
      operationId: exchangeApiKey
      tags: [auth]
      description: |
        Exchanges an API key (esrx_u_ user key or esrx_o_ org service-account key)
        for a short-lived claims-v2 access JWT carrying via:"api_key". User keys
        re-read the live membership at every exchange; org keys act with their
        fixed role. Effective tools = the org's enabled tools ∩ the key's allowlist.
      summary: Exchange an API key for an access token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [api_key]
              properties:
                api_key: { type: string }
      responses:
        "200":
          description: Access token issued (no refresh token — re-exchange the key)
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  expires_at: { type: string, format: date-time }
                  org: { type: object }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid, revoked, or expired API key — or its workspace access no longer exists
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/auth/logout:
    post:
      operationId: logout
      tags: [auth]
      description: Revokes the presented refresh token server-side. Idempotent from the client's perspective.
      summary: Revoke a refresh token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token: { type: string }
      responses:
        "200":
          description: Signed out
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }

  /v1/auth/switch-org:
    post:
      operationId: switchOrg
      tags: [auth]
      summary: Switch the active organization
      description: >-
        Mints a new org-scoped access token for a workspace the caller belongs
        to and records it as the default. The refresh token is unchanged.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [org_id]
              properties:
                org_id: { type: string }
      responses:
        "200":
          description: New org-scoped access token
          content:
            application/json:
              schema:
                type: object
                required: [access_token, expires_at, org]
                properties:
                  access_token: { type: string }
                  expires_at: { type: string, format: date-time }
                  org: { $ref: "#/components/schemas/OrgContext" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller is not a member of that organization
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/auth/me:
    get:
      operationId: me
      tags: [auth]
      description: Returns the user identified by the bearer access token.
      summary: The authenticated user
      responses:
        "200":
          description: Current user
          content:
            application/json:
              schema:
                type: object
                required: [user, org]
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  org: { $ref: "#/components/schemas/OrgContext" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: User not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/auth/verify:
    get:
      operationId: verifyToken
      tags: [auth]
      description: Cheap bearer-token validity check for sibling services and frontends; echoes the token's identity claims.
      summary: Lightweight bearer-token check for other services
      responses:
        "200":
          description: Token is valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  user_id: { type: string }
                  email: { type: string }
                  name: { type: string }
                  org_id: { type: string }
                  org_role: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"


  # ======================= accounts surface: me =======================

  /v1/me:
    patch:
      operationId: updateMe
      tags: [me]
      summary: Update profile
      description: Partial update of name/avatar_url. Reachable by API-key sessions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                avatar_url: { type: string }
      responses:
        "200":
          description: Updated user
          content:
            application/json:
              schema:
                type: object
                required: [user]
                properties:
                  user: { $ref: "#/components/schemas/User" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteAccount
      tags: [me]
      summary: Delete account
      description: >-
        Full sessions only. Refused while the caller is the sole owner of a
        team org that still has other members (dynamic 409 message).
      responses:
        "200":
          description: Account deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Sole owner of a team org with other members
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/me/password:
    post:
      operationId: setPassword
      tags: [me]
      summary: Set or change password
      description: >-
        Full sessions only. current_password is required only when the account
        already has one (magic-link accounts may set their first password).
        Wrong current password returns 403, not 401.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_password]
              properties:
                current_password: { type: string }
                new_password: { type: string, minLength: 8 }
      responses:
        "200":
          description: Password updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/me/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags: [me]
      summary: Resend the email confirmation link
      description: >-
        Full sessions only. No-ops with a 200 if the account is already
        verified, or if a still-usable link was sent within the last minute
        (throttled so this can't be used to mail-bomb an inbox).
      responses:
        "200":
          description: >-
            Always 200 — the message distinguishes already-verified,
            throttled, and sent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/me/orgs:
    get:
      operationId: myOrgs
      tags: [me]
      summary: Workspace switcher list
      description: >-
        Every org the caller belongs to (personal org self-healed first), each
        with member_count and is_default. No tools field here.
      responses:
        "200":
          description: Orgs
          content:
            application/json:
              schema:
                type: object
                required: [orgs]
                properties:
                  orgs:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/Org"
                        - type: object
                          required: [member_count, is_default]
                          properties:
                            member_count: { type: integer }
                            is_default: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/me/sessions:
    get:
      operationId: listSessions
      tags: [me]
      summary: Active sessions
      description: Full sessions only. Unrevoked, unexpired refresh-token sessions, newest first.
      responses:
        "200":
          description: Sessions
          content:
            application/json:
              schema:
                type: object
                required: [sessions]
                properties:
                  sessions:
                    type: array
                    items: { $ref: "#/components/schemas/SessionInfo" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/me/sessions/{sid}:
    parameters:
      - { name: sid, in: path, required: true, schema: { type: string } }
    delete:
      operationId: revokeSession
      tags: [me]
      summary: Revoke a session
      description: Full sessions only. Already-revoked sessions read as not found.
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/me/grants:
    get:
      operationId: listGrants
      tags: [me]
      summary: Connected OAuth apps
      description: Full sessions only.
      responses:
        "200":
          description: Grants
          content:
            application/json:
              schema:
                type: object
                required: [grants]
                properties:
                  grants:
                    type: array
                    items: { $ref: "#/components/schemas/Grant" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/me/grants/{gid}:
    parameters:
      - { name: gid, in: path, required: true, schema: { type: string } }
    delete:
      operationId: revokeGrant
      tags: [me]
      summary: Disconnect an app
      description: Full sessions only.
      responses:
        "200":
          description: Disconnected
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/me/api-keys:
    get:
      operationId: listUserApiKeys
      tags: [me]
      summary: List user API keys
      description: Full sessions only. Unrevoked keys, newest first; role key omitted on user keys.
      responses:
        "200":
          description: Keys
          content:
            application/json:
              schema:
                type: object
                required: [api_keys]
                properties:
                  api_keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createUserApiKey
      tags: [me]
      summary: Create a user API key
      description: >-
        Full sessions only. Returns 200 (not 201) with the key including its
        show-once secret. org_id defaults to the personal org; role is
        ignored for user keys; expires_in_days null = 365, 0 = never.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2 }
                org_id: { type: string }
                tools:
                  type: array
                  items: { type: string }
                expires_in_days: { type: integer, nullable: true }
      responses:
        "200":
          description: Key with show-once secret
          content:
            application/json:
              schema:
                type: object
                required: [api_key]
                properties:
                  api_key: { $ref: "#/components/schemas/ApiKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/me/api-keys/{kid}:
    parameters:
      - { name: kid, in: path, required: true, schema: { type: string } }
    delete:
      operationId: revokeUserApiKey
      tags: [me]
      summary: Revoke a user API key
      description: Full sessions only.
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  # ======================= accounts surface: orgs =======================

  /v1/orgs:
    post:
      operationId: createOrg
      tags: [orgs]
      summary: Create a team organization
      description: >-
        Any authenticated user. Slug auto-derived; caller becomes owner and the
        new org becomes their default. Returns 200 with a new org-scoped
        access token (org payload without tools here).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2 }
      responses:
        "200":
          description: Org created, org-scoped token issued
          content:
            application/json:
              schema:
                type: object
                required: [org, access_token, expires_at]
                properties:
                  org: { $ref: "#/components/schemas/Org" }
                  access_token: { type: string }
                  expires_at: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getOrg
      tags: [orgs]
      summary: Org profile
      description: Any member. Org plus member_count and team_count (no tools).
      responses:
        "200":
          description: Org
          content:
            application/json:
              schema:
                type: object
                required: [org]
                properties:
                  org:
                    allOf:
                      - $ref: "#/components/schemas/Org"
                      - type: object
                        required: [member_count, team_count]
                        properties:
                          member_count: { type: integer }
                          team_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
    patch:
      operationId: updateOrg
      tags: [orgs]
      summary: Update org settings
      description: >-
        Team orgs only; requires org.settings.manage (owner/admin). Success
        returns the GET shape. Slug collisions 409; empty effective patch 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 2 }
                slug: { type: string }
                avatar_url: { type: string }
      responses:
        "200":
          description: Updated org (GET shape)
          content:
            application/json:
              schema:
                type: object
                required: [org]
                properties:
                  org:
                    allOf:
                      - $ref: "#/components/schemas/Org"
                      - type: object
                        properties:
                          member_count: { type: integer }
                          team_count: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "409":
          description: Slug taken
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteOrg
      tags: [orgs]
      summary: Delete an organization
      description: Full sessions only; team orgs only; owner only (org.delete). Soft delete.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/members:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listMembers
      tags: [members]
      summary: Member directory
      description: Any member. Ordered by join date.
      responses:
        "200":
          description: Members
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items: { $ref: "#/components/schemas/Member" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/members/{uid}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: uid, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateMemberRole
      tags: [members]
      summary: Change a member's role
      description: >-
        Team orgs only; org.members.manage. Owner moves in either direction
        are owner-only; demoting the last owner 409s.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { type: string, enum: [owner, admin, billing, member] }
      responses:
        "200":
          description: Role updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "409":
          description: An organization needs at least one owner
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: removeMember
      tags: [members]
      summary: Remove a member (or leave)
      description: >-
        Team orgs only. Self-removal needs no permission; removing others
        needs org.members.manage; removing an owner is owner-only and the
        last owner 409s. Cleans up team memberships and default-org pointers.
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "409":
          description: An organization needs at least one owner
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/invitations:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listInvitations
      tags: [invitations]
      summary: Pending invitations
      description: Full sessions only; org.members.manage. Usable invites only; secrets are unrecoverable.
      responses:
        "200":
          description: Invitations
          content:
            application/json:
              schema:
                type: object
                required: [invitations]
                properties:
                  invitations:
                    type: array
                    items: { $ref: "#/components/schemas/Invitation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createInvitations
      tags: [invitations]
      summary: Invite people
      description: >-
        Full sessions only; team orgs only; org.members.manage. kind "email"
        (default) returns {invitations: [...]} one per valid email (each
        single-use, 14-day TTL, requested role honored). kind "link" returns
        {invitation: {..., url}} — always role member, unlimited uses.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind: { type: string, enum: [email, link] }
                emails:
                  type: array
                  items: { type: string }
                role: { type: string, enum: [member, admin] }
      responses:
        "200":
          description: Created — email kind yields invitations[], link kind yields invitation with url
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [invitations]
                    properties:
                      invitations:
                        type: array
                        items: { $ref: "#/components/schemas/Invitation" }
                  - type: object
                    required: [invitation]
                    properties:
                      invitation: { $ref: "#/components/schemas/Invitation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/invitations/{iid}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: iid, in: path, required: true, schema: { type: string } }
    delete:
      operationId: revokeInvitation
      tags: [invitations]
      summary: Revoke an invitation
      description: Full sessions only; org.members.manage.
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }

  /v1/orgs/{id}/teams:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listTeams
      tags: [teams]
      summary: List teams
      description: Any member. Empty on personal orgs.
      responses:
        "200":
          description: Teams
          content:
            application/json:
              schema:
                type: object
                required: [teams]
                properties:
                  teams:
                    type: array
                    items: { $ref: "#/components/schemas/Team" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createTeam
      tags: [teams]
      summary: Create a team
      description: >-
        Team orgs only; any org member — the creator becomes the team lead.
        Returns 200 (not 201) with member_count 1 and my_role lead.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2 }
                description: { type: string }
      responses:
        "200":
          description: Team created
          content:
            application/json:
              schema:
                type: object
                required: [team]
                properties:
                  team: { $ref: "#/components/schemas/Team" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/teams/{tid}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: tid, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateTeam
      tags: [teams]
      summary: Update a team
      description: Team orgs only; team lead or org.teams.manage. Returns a message, not the team.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 2 }
                description: { type: string }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteTeam
      tags: [teams]
      summary: Delete a team
      description: Team orgs only; team lead or org.teams.manage. Soft delete.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/teams/{tid}/members:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: tid, in: path, required: true, schema: { type: string } }
    get:
      operationId: listTeamMembers
      tags: [teams]
      summary: Team members
      description: Any org member. Same row shape as the org directory; role is lead | member.
      responses:
        "200":
          description: Members
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items: { $ref: "#/components/schemas/Member" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: addTeamMember
      tags: [teams]
      summary: Add a team member
      description: >-
        Team orgs only; team lead or org.teams.manage. Target must already be
        an org member (400, not 404, otherwise). Upserts the role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id: { type: string }
                role: { type: string, enum: [lead, member] }
      responses:
        "200":
          description: Added
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/teams/{tid}/members/{uid}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: tid, in: path, required: true, schema: { type: string } }
      - { name: uid, in: path, required: true, schema: { type: string } }
    delete:
      operationId: removeTeamMember
      tags: [teams]
      summary: Remove a team member
      description: Team orgs only. Self-removal always allowed; otherwise team lead or org.teams.manage.
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }

  /v1/orgs/{id}/tools:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listToolAccess
      tags: [tools]
      summary: Tool access matrix
      description: Any member. Always exactly the seven known tools, in canonical order.
      responses:
        "200":
          description: Tools
          content:
            application/json:
              schema:
                type: object
                required: [tools]
                properties:
                  tools:
                    type: array
                    items: { $ref: "#/components/schemas/ToolAccess" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    put:
      operationId: setToolAccess
      tags: [tools]
      summary: Toggle a tool
      description: >-
        Team orgs only; org.tools.manage. Despite PUT, a single-tool upsert.
        Success returns the full GET shape.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tool, enabled]
              properties:
                tool: { type: string }
                enabled: { type: boolean }
      responses:
        "200":
          description: Full tool matrix after the change
          content:
            application/json:
              schema:
                type: object
                required: [tools]
                properties:
                  tools:
                    type: array
                    items: { $ref: "#/components/schemas/ToolAccess" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/api-keys:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listOrgApiKeys
      tags: [api-keys]
      summary: List org API keys
      description: Full sessions only; org.settings.manage.
      responses:
        "200":
          description: Keys
          content:
            application/json:
              schema:
                type: object
                required: [api_keys]
                properties:
                  api_keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createOrgApiKey
      tags: [api-keys]
      summary: Create an org API key
      description: >-
        Full sessions only; team orgs only; org.settings.manage. role honored
        (member/billing/admin, never owner). 200 with show-once secret.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2 }
                role: { type: string, enum: [member, billing, admin] }
                tools:
                  type: array
                  items: { type: string }
                expires_in_days: { type: integer, nullable: true }
      responses:
        "200":
          description: Key with show-once secret
          content:
            application/json:
              schema:
                type: object
                required: [api_key]
                properties:
                  api_key: { $ref: "#/components/schemas/ApiKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/api-keys/{kid}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: kid, in: path, required: true, schema: { type: string } }
    delete:
      operationId: revokeOrgApiKey
      tags: [api-keys]
      summary: Revoke an org API key
      description: Full sessions only; org.settings.manage.
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/audit:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listAudit
      tags: [audit]
      summary: Audit log
      description: >-
        org.settings.manage. Reads platform.audit_log, so entries span every
        Easerix tool, not just identity. Latest 100 by default, no
        pagination. meta is a JSON-encoded string; actor_id must be resolved
        by the client, and may name a user who no longer exists.
      parameters:
        - name: tool
          in: query
          schema: { type: string }
          description: Restrict to one product's entries.
        - name: action
          in: query
          schema: { type: string }
          description: Restrict to one action, e.g. member.role_changed.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          description: Entries
          content:
            application/json:
              schema:
                type: object
                required: [entries, tools]
                properties:
                  entries:
                    type: array
                    maxItems: 500
                    items: { $ref: "#/components/schemas/AuditEntry" }
                  tools:
                    type: array
                    description: >-
                      Distinct tools present in this org's log, so a client can
                      offer only filters that can match.
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  # ========================= accounts surface: domains =========================

  /v1/orgs/{id}/domains:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listDomains
      tags: [domains]
      summary: List claimed domains
      description: org.domains.manage. Unverified rows carry the TXT proof to publish.
      responses:
        "200":
          description: Domains
          content:
            application/json:
              schema:
                type: object
                required: [domains]
                properties:
                  domains:
                    type: array
                    items: { $ref: "#/components/schemas/OrgDomain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createDomain
      tags: [domains]
      summary: Claim a domain
      description: >-
        org.domains.manage, team orgs only. The domain is normalized (scheme,
        www, path and trailing dot stripped, lowercased). Public email
        providers are rejected, as is a domain already verified elsewhere.
        The claim starts unverified and returns the TXT record to publish.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, example: "acme.com" }
                mode: { type: string, enum: [suggest, auto_join], default: suggest }
      responses:
        "200":
          description: Claimed (unverified)
          content:
            application/json:
              schema:
                type: object
                required: [domain]
                properties:
                  domain: { $ref: "#/components/schemas/OrgDomain" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "409":
          description: Already claimed here, or verified by another organization
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/domains/{did}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: did, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateDomain
      tags: [domains]
      summary: Switch suggest / auto-join
      description: org.domains.manage.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode: { type: string, enum: [suggest, auto_join] }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [domain]
                properties:
                  domain: { $ref: "#/components/schemas/OrgDomain" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteDomain
      tags: [domains]
      summary: Remove a domain claim
      description: >-
        org.domains.manage. Existing members are unaffected — only future
        discovery stops.
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/orgs/{id}/domains/{did}/verify:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: did, in: path, required: true, schema: { type: string } }
    post:
      operationId: verifyDomain
      tags: [domains]
      summary: Check the DNS TXT proof
      description: >-
        org.domains.manage. Reads TXT at _easerix.<domain> and matches the
        issued token. A missing or mismatched record is a 400 with a
        propagation hint, not an error state — clients may poll. Already
        verified returns 200 unchanged.
      responses:
        "200":
          description: Verified (or already was)
          content:
            application/json:
              schema:
                type: object
                required: [domain]
                properties:
                  domain: { $ref: "#/components/schemas/OrgDomain" }
                  message: { type: string }
        "400":
          description: The TXT record isn't readable or doesn't match yet
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/OrgNotFound" }
        "409":
          description: Another organization verified the domain first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/me/discoverable:
    get:
      operationId: discoverable
      tags: [domains]
      summary: The organization offered by your email domain
      description: >-
        Returns a suggestion only for a verified domain in suggest mode that
        the caller isn't already a member of — otherwise suggestion is null.
        auto_join domains never surface here (they add on signup instead).
      responses:
        "200":
          description: Suggestion or null
          content:
            application/json:
              schema:
                type: object
                required: [suggestion]
                properties:
                  suggestion:
                    nullable: true
                    allOf: [{ $ref: "#/components/schemas/DomainSuggestion" }]
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/me/discoverable/join:
    post:
      operationId: joinDiscoverable
      tags: [domains]
      summary: Accept the suggestion
      description: >-
        Joins as member and returns an access token already switched into the
        organization. Idempotent for an existing member.
      responses:
        "200":
          description: Joined
          content:
            application/json:
              schema:
                type: object
                required: [org, access_token, expires_at]
                properties:
                  org: { $ref: "#/components/schemas/OrgContext" }
                  access_token: { type: string }
                  expires_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: No organization is offered for this email domain
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

  # ======================= accounts surface: invitations =======================

  /v1/invitations/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    get:
      operationId: previewInvitation
      tags: [invitations]
      summary: Preview an invitation (public)
      description: >-
        Unauthenticated — renders the accept page before sign-in. Reduced org
        shape (name, slug, avatar_url only). 410 for exhausted/revoked invites
        or a deleted org.
      security: []
      responses:
        "200":
          description: Invitation preview
          content:
            application/json:
              schema:
                type: object
                required: [org, role, kind, inviter]
                properties:
                  org:
                    type: object
                    required: [name, slug, avatar_url]
                    properties:
                      name: { type: string }
                      slug: { type: string }
                      avatar_url: { type: string }
                  role: { type: string }
                  kind: { type: string }
                  inviter: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: Invitation no longer active, or the organization no longer exists
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/invitations/{token}/accept:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      operationId: acceptInvitation
      tags: [invitations]
      summary: Accept an invitation
      description: >-
        Full sessions only. Email invites are bound to the invited address.
        Idempotent for existing members (already_member true). Returns a new
        org-scoped access token; the refresh token is unchanged.
      responses:
        "200":
          description: Joined (or already a member)
          content:
            application/json:
              schema:
                type: object
                required: [org, access_token, expires_at, already_member]
                properties:
                  org: { $ref: "#/components/schemas/OrgContext" }
                  access_token: { type: string }
                  expires_at: { type: string, format: date-time }
                  already_member: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: Invitation no longer active, or the organization no longer exists
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    User:
      type: object
      required: [id, email, name, email_verified]
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        name: { type: string }
        avatar_url: { type: string }
        email_verified: { type: boolean }
    Session:
      description: >-
        Issued on register/login/verify/refresh. Since the accounts work the
        access JWT carries org claims and the response includes the active org.
      type: object
      required: [access_token, refresh_token, expires_at, user, org]
      properties:
        access_token:
          type: string
          description: HS256 JWT, 1h TTL
        refresh_token:
          type: string
          description: Opaque secret; store securely, shown once
        expires_at:
          type: string
          format: date-time
          description: Access-token expiry
        user: { $ref: "#/components/schemas/User" }
        org: { $ref: "#/components/schemas/OrgContext" }
    Org:
      description: An organization as seen by the current member (implicit personal org included).
      type: object
      required: [id, slug, name, kind, role, permissions]
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        kind: { type: string, description: personal | team }
        avatar_url: { type: string }
        role: { type: string, description: owner | admin | billing | member }
        permissions:
          type: array
          description: "Fixed order subset of: org.members.manage, org.teams.manage, org.tools.manage, org.settings.manage, org.domains.manage, org.billing.manage, org.delete"
          items: { type: string }
    OrgContext:
      description: Session-response org payload — the org plus its enabled tool slugs.
      allOf:
        - $ref: "#/components/schemas/Org"
        - type: object
          required: [tools]
          properties:
            tools:
              type: array
              items: { type: string }
    Member:
      description: Org directory row (also used for team members, where role is lead | member).
      type: object
      required: [user_id, email, name, avatar_url, role, joined_at]
      properties:
        user_id: { type: string, format: uuid }
        email: { type: string }
        name: { type: string }
        avatar_url: { type: string }
        role: { type: string }
        joined_at: { type: string, format: date-time }
    Invitation:
      type: object
      required: [id, kind, role, max_uses, use_count, expires_at, created_at]
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, description: email | link }
        email: { type: string, nullable: true }
        role: { type: string, description: member | admin }
        invited_by: { type: string, nullable: true }
        max_uses: { type: integer, description: 0 = unlimited }
        use_count: { type: integer }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        url: { type: string, description: "Link invites only, on creation" }
        dev_link: { type: string, description: Non-production without SMTP only }
    Team:
      type: object
      required: [id, slug, name, description, member_count, my_role, created_at]
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        description: { type: string }
        member_count: { type: integer }
        my_role: { type: string, description: lead | member | "" when not on the team }
        created_at: { type: string, format: date-time }
    ToolAccess:
      type: object
      required: [tool, enabled]
      properties:
        tool: { type: string }
        enabled: { type: boolean }
    ApiKey:
      type: object
      required: [id, kind, name, org_id, token_prefix, created_at]
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, description: user | org }
        name: { type: string }
        org_id: { type: string }
        token_prefix: { type: string }
        role: { type: string, description: Org keys only — member | billing | admin }
        tools:
          type: array
          nullable: true
          description: null = unrestricted
          items: { type: string }
        last_used_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        secret: { type: string, description: "Present exactly once, on creation" }
    SessionInfo:
      type: object
      required: [id, user_agent, created_at, expires_at]
      properties:
        id: { type: string, format: uuid }
        user_agent: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
    Grant:
      type: object
      required: [id, client_name, org_id, org_name, org_kind, created_at]
      properties:
        id: { type: string, format: uuid }
        client_name: { type: string }
        org_id: { type: string }
        org_name: { type: string }
        org_kind: { type: string }
        last_used_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    OrgDomain:
      type: object
      required: [id, domain, mode, verified_at, created_at]
      properties:
        id: { type: string, format: uuid }
        domain: { type: string }
        mode: { type: string, enum: [suggest, auto_join] }
        verified_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        verification_record:
          description: Present only while unverified — the DNS proof to publish.
          type: object
          properties:
            type: { type: string, example: TXT }
            name: { type: string, example: "_easerix.acme.com" }
            value: { type: string, example: "easerix-verification=<token>" }

    DomainSuggestion:
      type: object
      required: [org_id, name, slug, avatar_url, domain, member_count]
      properties:
        org_id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        avatar_url: { type: string }
        domain: { type: string }
        member_count: { type: integer }

    AuditEntry:
      type: object
      required: [id, org_id, tool, actor_id, via, action, target_type, target_id, meta, created_at]
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string }
        tool:
          type: string
          description: >-
            Which product recorded it — "auth" for identity, else a tool slug.
        actor_id:
          type: string
          description: >-
            Never null: the log carries no foreign keys, so an actor id
            survives the deletion of the user it names. May be empty on rows
            backfilled from before the platform log existed.
        via:
          type: string
          description: >-
            How the actor authenticated. Empty for a browser session,
            otherwise api_key, oauth, cli or system.
        action: { type: string }
        target_type: { type: string }
        target_id: { type: string }
        meta: { type: string, description: "JSON-encoded string, not a nested object" }
        ip: { type: string }
        user_agent: { type: string }
        created_at: { type: string, format: date-time }
    Message:
      type: object
      required: [message]
      properties:
        message: { type: string }
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
  responses:
    BadRequest:
      description: Invalid request body
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found (session, invitation, grant, or key)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: >-
        Permission denied — role gate, personal-workspace management, or an
        API-key/OAuth session on an identity route
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    OrgNotFound:
      description: Organization not found — also returned when the caller is not a member (non-enumeration)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServerError:
      description: Internal error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

security:
  - bearer: []
