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.
    Google sign-in is an authorization-code exchange with PKCE: the login
    app starts it, Google redirects back to the login app, and that page
    exchanges the code here for a session.
    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: workspaces
    description: Organizations
  - name: members
    description: Org membership and roles
  - name: invitations
    description: Inviting people
  - name: teams
    description: Teams within a workspace
  - name: tools
    description: Per-workspace 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

  /docs:
    get:
      operationId: apiReference
      tags: [system]
      summary: API reference UI (password-protected)
      description: >-
        Scalar API reference for the running instance, gated by HTTP Basic
        Auth (DOCS_USER/DOCS_PASSWORD) — not linked from any product surface.
        Unlike tasks/links, auth is the platform's identity boundary, so this
        route is only registered at all when both env vars are set; an
        unconfigured deployment 404s here instead of serving it open.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The reference UI
          content:
            text/html:
              schema:
                type: string

  /docs/openapi.yaml:
    get:
      operationId: openAPISpec
      tags: [system]
      summary: This contract, served by the running instance (password-protected)
      description: >-
        The embedded copy of this OpenAPI document — backs the reference UI
        above. Same Basic Auth gate as /docs.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The OpenAPI document
          content:
            text/plain:
              schema:
                type: string

  /v1/auth/register:
    post:
      operationId: register
      tags: [auth]
      description: >-
        Creates an account with email_verified: false and emails a
        confirmation link (best-effort). No session is issued until the
        address is proven (202 verification_required): the owner opens the
        link, then signs in with the password. A password sign-in before that
        is refused with 403 email_unverified and a fresh link is sent. Domain
        auto-join/discovery is withheld until the address is confirmed.
      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 }
                next:
                  type: string
                  description: >-
                    Where the confirmation link should return to. Kept only when
                    it is a first-party Easerix page (https, an exact Easerix
                    host); otherwise ignored.
      responses:
        "202":
          description: Account created; confirm the email address, then sign in. No session yet.
          content:
            application/json:
              schema:
                type: object
                required: [verification_required, email, message]
                properties:
                  verification_required: { type: boolean }
                  email: { type: string, format: email }
                  message: { type: string }
                  continue_key:
                    type: string
                    description: >-
                      A random key for the registering client only. Presented with
                      the confirmation token to /v1/auth/verify-email, it turns the
                      confirmation into a sign-in. Only its hash is stored.
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: An account with that email already exists
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Too many attempts (per-IP or per-instance limit); retry after the Retry-After seconds
          headers:
            Retry-After: { schema: { type: integer } }
          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; 403 email_unverified when the password is right but the address was never confirmed (a new link is sent).
      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" }
        "403":
          description: "Correct password, but the email address isn't confirmed yet (code: email_unverified). A fresh confirmation link is sent."
          content:
            application/json:
              schema:
                type: object
                required: [error, code]
                properties:
                  error: { type: string }
                  code: { type: string, enum: [email_unverified] }
        "429":
          description: "Too many attempts (10 failed passwords for the address in 15 minutes, or the per-IP limit); retry after the Retry-After seconds"
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/auth/google:
    get:
      operationId: googleSignInStatus
      tags: [auth]
      description: Reports whether Sign in with Google is configured. The client id and secret stay on this service.
      summary: Whether Google sign-in is available
      security: []
      responses:
        "200":
          description: Availability of Google sign-in
          content:
            application/json:
              schema:
                type: object
                required: [enabled]
                properties:
                  enabled: { type: boolean }
    post:
      operationId: completeGoogleSignIn
      tags: [auth]
      description: >-
        Exchanges a Google authorization code (PKCE) for a session. The code
        was issued to an allowlisted login-app callback. A verified Google
        email signs in the matching account or creates one. An unverified
        local account is taken over the same way a magic link does: the
        previous password and sessions are cleared. An email already linked
        to a different Google account is refused.
      summary: Exchange a Google authorization code for a session
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, redirect_uri, code_verifier]
              properties:
                code: { type: string }
                redirect_uri: { type: string }
                code_verifier: { type: string, minLength: 43, maxLength: 128 }
      responses:
        "200":
          description: Session issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Google did not confirm the sign-in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: This email is already linked to a different Google account
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Too many attempts; retry after the Retry-After seconds
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          description: Google sign-in is not configured, or Google could not be reached
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/auth/google/start:
    get:
      operationId: startGoogleSignIn
      tags: [auth]
      description: >-
        Redirects the browser to Google's authorization endpoint. redirect_uri
        must be the login app's /google/callback (localhost only outside
        production). state and code_challenge are the PKCE values the login
        app generated. The client secret never appears on this redirect.
      summary: Start Google sign-in
      security: []
      parameters:
        - name: redirect_uri
          in: query
          required: true
          schema: { type: string }
        - name: state
          in: query
          required: true
          schema: { type: string, minLength: 43, maxLength: 128 }
        - name: code_challenge
          in: query
          required: true
          schema: { type: string, minLength: 43, maxLength: 128 }
        - name: login_hint
          in: query
          required: false
          schema: { type: string, format: email }
      responses:
        "302":
          description: Redirect to Google
          headers:
            Location:
              schema: { type: string }
              description: https://accounts.google.com/o/oauth2/v2/auth
        "400":
          $ref: "#/components/responses/BadRequest"
        "503":
          description: Google sign-in is not configured
          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 the same 200 for valid bodies, including
        when no link is sent because the address already had one in the last
        minute (or 5 in the last hour) or the hourly outbound-email budget is
        spent, so the response never reveals either. 429 only for the per-IP
        limit.
      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.
                next:
                  type: string
                  description: >-
                    Where the web link should return to after sign-in. Kept only
                    when it is a first-party Easerix page; otherwise ignored.
      responses:
        "200":
          description: Link created (and emailed when SMTP is configured)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                additionalProperties: true
        "429":
          description: Too many attempts (per-IP or per-instance limit); retry after the Retry-After seconds
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "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.
        With the continue_key that /v1/auth/register gave the registering
        client, the request that confirms the address also signs the person in
        and returns a session; the emailed token alone never does.
      summary: Confirm a password account's email address
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                continue_key: { type: string, description: The key /v1/auth/register returned to this client }
      responses:
        "200":
          description: >-
            Email confirmed. With a matching continue_key, a session
            (access_token, refresh_token, user) instead.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  user: { $ref: "#/components/schemas/User" }
                  access_token: { type: string }
                  refresh_token: { type: string }
                additionalProperties: true
        "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_ workspace 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; workspace keys act with their
        fixed role. Effective tools = the workspace'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 }
                  workspace: { 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/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, workspace]
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  workspace: { $ref: "#/components/schemas/WorkspaceContext" }
        "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 }
                  workspace_id: { type: string }
                  workspace_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 workspace 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 workspace 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; a magic-link account may set its first password only
        within 10 minutes of signing in (else 403 reauth_required). Wrong
        current password returns 403, not 401. A change ends every other
        session: all refresh tokens except keep_refresh_token are revoked and
        access tokens already minted stop working (identity epoch), so the
        caller refreshes on the session it kept.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_password]
              properties:
                current_password: { type: string }
                new_password: { type: string, minLength: 8 }
                keep_refresh_token:
                  type: string
                  description: The caller's own refresh token; the one session that survives the change.
      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/attribution:
    post:
      operationId: recordSignupAttribution
      tags: [me]
      summary: Record where the caller's account came from
      description: >-
        Full sessions only. The login app posts the first-touch sign-up
        source once, right after a new account's first session. The first
        post within a day of the account's creation is kept; later posts and
        posts for older accounts are ignored. Values that aren't plain
        campaign tags, known tool names, a hostname or a path are dropped:
        a referrer is cut to its host and a landing page to the first two
        segments of its path, so nothing that identifies a person and no
        capability link is stored. The body is limited to 4 KB.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignupAttribution" }
      responses:
        "204": { description: Accepted (also when ignored) }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /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. workspace_id defaults to the personal workspace; 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 }
                workspace_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: workspaces ====================

  /v1/auth/switch-workspace:
    post:
      operationId: switchWorkspace
      tags: [auth]
      summary: Switch the active workspace
      description: >-
        Mints a new workspace-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
              properties:
                workspace_id: { type: string, description: "The workspace to switch to" }
      responses:
        "200":
          description: New workspace-scoped access token
          content:
            application/json:
              schema:
                type: object
                required: [access_token, expires_at, workspace]
                properties:
                  access_token: { type: string }
                  expires_at: { type: string, format: date-time }
                  workspace: { $ref: "#/components/schemas/WorkspaceContext" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller is not a member of that workspace
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/me/workspaces:
    get:
      operationId: myWorkspaces
      tags: [me]
      summary: Workspace switcher list
      description: >-
        Every workspace the caller belongs to (personal workspace self-healed first), each
        with member_count, is_default, and the caller's teams in that workspace. No tools
        field here — tool access is per active workspace and arrives with the session.
      responses:
        "200":
          description: Orgs
          content:
            application/json:
              schema:
                type: object
                required: [workspaces]
                properties:
                  workspaces:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/Workspace"
                        - type: object
                          required: [member_count, is_default, teams]
                          properties:
                            member_count: { type: integer }
                            is_default: { type: boolean }
                            teams:
                              type: array
                              description: >
                                The caller's own teams in that workspace, in creation
                                order. Empty array when they are on none.
                              items: { $ref: "#/components/schemas/TeamRef" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces:
    post:
      operationId: createWorkspace
      tags: [workspaces]
      summary: Create a team workspace
      description: >-
        Any authenticated user. Slug auto-derived; caller becomes owner and the
        new workspace becomes their default. Returns 200 with a new
        workspace-scoped access token (workspace payload without tools here).

        A "General" team is created with the workspace and the caller becomes
        its lead. Everyone who joins later is enrolled in it automatically, so
        no member is ever left with an empty teams[] claim.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2 }
      responses:
        "200":
          description: Workspace created, workspace-scoped token issued
          content:
            application/json:
              schema:
                type: object
                required: [workspace, access_token, expires_at]
                properties:
                  workspace: { $ref: "#/components/schemas/Workspace" }
                  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/workspaces/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getWorkspace
      tags: [workspaces]
      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: [workspace]
                properties:
                  workspace:
                    allOf:
                      - $ref: "#/components/schemas/Workspace"
                      - 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/WorkspaceNotFound" }
    patch:
      operationId: updateWorkspace
      tags: [workspaces]
      summary: Update workspace settings
      description: >-
        Team workspaces 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 workspace (GET shape)
          content:
            application/json:
              schema:
                type: object
                required: [workspace]
                properties:
                  workspace:
                    allOf:
                      - $ref: "#/components/schemas/Workspace"
                      - 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/WorkspaceNotFound" }
        "409":
          description: Slug taken
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteWorkspace
      tags: [workspaces]
      summary: Delete a workspace
      description: Full sessions only; team workspaces 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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 workspaces 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/WorkspaceNotFound" }
        "409":
          description: A workspace 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 workspaces 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-workspace 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/WorkspaceNotFound" }
        "409":
          description: A workspace needs at least one owner
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createInvitations
      tags: [invitations]
      summary: Invite people
      description: >-
        Full sessions only; team workspaces 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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/WorkspaceNotFound" }

  /v1/workspaces/{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 workspaces.
      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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createTeam
      tags: [teams]
      summary: Create a team
      description: >-
        Team workspaces only; requires org.teams.manage (owner or admin) — the same
        permission as rename and delete. 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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 workspaces 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteTeam
      tags: [teams]
      summary: Delete a team
      description: >-
        Team workspaces only; team lead or org.teams.manage. Soft delete.

        The workspace's DEFAULT team cannot be deleted and answers 409 with
        `code: default_team`. Everyone who joins the workspace belongs to it,
        so deleting it dropped every roster row, left the team's content
        unreachable in the tools, and was then rebuilt empty by the next join
        — carrying only the owner and that joiner, leaving everyone else on no
        team until the service next converged. An admin tightening access
        wants to remove members, which is a different call. The default team
        goes when the workspace goes.
      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/WorkspaceNotFound" }
        "409":
          description: The default team cannot be deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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 workspace member. Same row shape as the workspace 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: addTeamMember
      tags: [teams]
      summary: Add a team member
      description: >-
        Team workspaces only; team lead or org.teams.manage. Target must already be
        a workspace 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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 workspaces 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/WorkspaceNotFound" }

  /v1/workspaces/{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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    put:
      operationId: setToolAccess
      tags: [tools]
      summary: Toggle a tool
      description: >-
        Team workspaces 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{id}/api-keys:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listWorkspaceApiKeys
      tags: [api-keys]
      summary: List workspace 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createWorkspaceApiKey
      tags: [api-keys]
      summary: Create a workspace API key
      description: >-
        Full sessions only; team workspaces only; org.settings.manage. role honored
        (member or admin, never owner). 200 with show-once secret.


        `billing` is refused with `code: billing_key_unusable`. A key exists to
        call APIs and there is no API a billing principal can call: every
        product gates on the tool check, which refuses a role holding no
        product seat, and org.billing.manage is carried by the matrix but used
        by no route. The key would authenticate and 403 on everything else, so
        it is refused at creation rather than discovered in production.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2 }
                role: { type: string, enum: [member, 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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: revokeWorkspaceApiKey
      tags: [api-keys]
      summary: Revoke a workspace 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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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 workspace'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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

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

  /v1/workspaces/{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/WorkspaceDomain" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createDomain
      tags: [domains]
      summary: Claim a domain
      description: >-
        org.domains.manage, team workspaces 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/WorkspaceDomain" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/WorkspaceNotFound" }
        "409":
          description: Already claimed here, or verified by another workspace
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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/WorkspaceDomain" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/WorkspaceNotFound" }
        "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/WorkspaceNotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/workspaces/{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/WorkspaceDomain" }
                  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/WorkspaceNotFound" }
        "409":
          description: Another workspace 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 workspace 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
        workspace. Idempotent for an existing member.

        The user is also enrolled in the workspace's "General" team in the same
        transaction, so the returned token's teams[] already carries it.
      responses:
        "200":
          description: Joined
          content:
            application/json:
              schema:
                type: object
                required: [workspace, access_token, expires_at]
                properties:
                  workspace: { $ref: "#/components/schemas/WorkspaceContext" }
                  access_token: { type: string }
                  expires_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: No workspace 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 workspace
        shape (name, slug, avatar_url only). 410 for exhausted/revoked invites
        or a deleted workspace.
      security: []
      responses:
        "200":
          description: Invitation preview
          content:
            application/json:
              schema:
                type: object
                required: [workspace, role, kind, inviter]
                properties:
                  workspace:
                    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 workspace 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
        workspace-scoped access token; the refresh token is unchanged.

        The accepting user is also enrolled in the workspace's "General" team
        (as a member) in the same transaction, so the returned token's teams[]
        already carries it. Applied to existing members too, so anyone who
        joined before this behaviour existed is enrolled on their next accept.
      responses:
        "200":
          description: Joined (or already a member)
          content:
            application/json:
              schema:
                type: object
                required: [workspace, access_token, expires_at, already_member]
                properties:
                  workspace: { $ref: "#/components/schemas/WorkspaceContext" }
                  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 workspace 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
    docsBasic:
      type: http
      scheme: basic
      description: >-
        Gates /docs and /docs/openapi.yaml only. Credentials come from the
        DOCS_USER/DOCS_PASSWORD env vars; if either is unset the routes are
        not registered at all (404), never served open.
  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/google. Since the accounts work the
        access JWT carries workspace claims and the response includes the active workspace.
      type: object
      required: [access_token, refresh_token, expires_at, user, workspace]
      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" }
        workspace: { $ref: "#/components/schemas/WorkspaceContext" }
    Workspace:
      description: A workspace as seen by the current member (implicit personal workspace 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 }
    WorkspaceContext:
      description: >
        Session-response workspace payload — the workspace plus its enabled tool
        slugs and the caller's teams. Returned by every path that establishes a
        session (login, register, magic-link, /v1/auth/me, switch-workspace,
        invite accept, domain join, API-key exchange), so one payload answers
        who the caller is here, what they may do, what is switched on, and which
        teams they are in.
      allOf:
        - $ref: "#/components/schemas/Workspace"
        - type: object
          required: [tools, teams]
          properties:
            tools:
              type: array
              items: { type: string }
            teams:
              type: array
              description: >
                The caller's own teams in this workspace, in creation order —
                not every team in it. Use GET /v1/workspaces/{id}/teams for the
                full list. Empty array when they are on none.
              items: { $ref: "#/components/schemas/TeamRef" }
    TeamRef:
      description: >
        A team the caller belongs to, as session responses carry it. The access
        token carries bare team ids (the teams[] claim is an authorization input
        for authkit.SubjectScope); the name and the caller's team role are
        presentation and travel here instead, read fresh on every session.
      type: object
      required: [id, slug, name, my_role]
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        my_role:
          type: string
          description: >
            lead | member. Always populated — this list holds only teams the
            caller is on, unlike the Team schema's my_role.
    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
      description: >-
        A team in the workspace. Every TEAM workspace has a default "General"
        team that all members are enrolled in automatically; any other team is
        membership-private — only the people a lead or admin adds are on it.
        Personal workspaces have no teams at all (one member, nothing to scope).
      required: [id, slug, name, description, member_count, my_role, created_at, is_default]
      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 }
        is_default:
          type: boolean
          description: >-
            True for the workspace's default team. It CANNOT be deleted:
            DELETE /v1/teams/{id} answers 409 with code default_team. Every
            member is auto-enrolled here, and because Tasks shows only the
            teams you belong to, deleting it would strand every member whose
            only team this was. Clients should offer removal of individual
            members instead of a delete control on a team with this flag set.
            Derived from the workspace pointer, not the name — the default team
            is renameable, so matching on "General" would be a guess.
    ToolAccess:
      type: object
      required: [tool, enabled]
      properties:
        tool: { type: string }
        enabled: { type: boolean }
    ApiKey:
      type: object
      required: [id, kind, name, workspace_id, token_prefix, created_at]
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, description: user | org }
        name: { type: string }
        workspace_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_id, client_name, workspace_id, workspace_name, workspace_kind, created_at]
      properties:
        id: { type: string, format: uuid }
        client_id: { type: string, description: "The OAuth client the grant was made to. The Easerix CLI is easerix-cli." }
        client_name: { type: string }
        workspace_id: { type: string }
        workspace_name: { type: string }
        workspace_kind: { type: string }
        last_used_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    WorkspaceDomain:
      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: [workspace_id, name, slug, avatar_url, domain, member_count]
      properties:
        workspace_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, workspace_id, tool, actor_id, via, action, target_type, target_id, meta, created_at]
      properties:
        id: { type: string, format: uuid }
        workspace_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 }
    SignupAttribution:
      type: object
      description: >-
        A first-touch sign-up source. Every field is optional; empty or
        invalid values are stored as empty.
      properties:
        utm_source: { type: string, maxLength: 100 }
        utm_medium: { type: string, maxLength: 100 }
        utm_campaign: { type: string, maxLength: 100 }
        utm_content: { type: string, maxLength: 100 }
        utm_term: { type: string, maxLength: 100 }
        ref: { type: string, maxLength: 100, description: e.g. "badge" }
        badge_tool: { type: string, description: The tool whose "Made with Easerix" badge was clicked }
        badge_page: { type: string, maxLength: 100, description: The id of the page that showed the badge }
        referrer_host: { type: string, description: Host only; a full URL is cut to its host }
        landing_path: { type: string, maxLength: 200, description: "Path only: a query or fragment is cut off, only the first two segments are kept, and a path holding @ or % is dropped" }
        first_tool: { type: string, description: "The tool the visitor was heading to (notes, forms, sign, links, tasks, canvas, crm, croncrunch, sites, notary, pro, hub)" }
    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" }
    WorkspaceNotFound:
      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: []
