openapi: 3.0.3
info:
  title: naturali.ai — Auth API
  version: 1.0.0
  description: >
    The human-session surface. Interactive clients (the app, native apps) sign in
    with a code emailed to the address or with a Google account for the same
    address — both reach one account — and receive a short-lived access JWT
    plus a rotating refresh token; programmatic callers use API tokens instead (see the API Keys API). Both resolve to the same
    project + capability authorization below the edge. Built on the ttoss
    auth-core primitives (signJwt, createRefreshRotation, generateOneTimeToken).

    This spec covers the credential flows only. The account behind a credential
    is the Users API: read it with `GET /v1/users/me`.
  contact:
    name: naturali.ai
    url: https://naturali.ai
servers:
  - url: '{baseUrl}'
    description: Host of your naturali.ai deployment; every path carries the /v1 prefix.
    variables:
      baseUrl:
        description: Base host URL.
        default: https://api.naturali.ai
tags:
  - name: Auth
    description: Sign in, refresh and end interactive human sessions.
security:
  - bearerAuth: []
  - oauth2:
      - mcp:access
paths:
  /v1/auth/code:
    post:
      tags: [Auth]
      summary: Email a sign-in code
      description: >
        Emails a short numeric code to the address. Always responds 200 with the
        same body whether or not the address has an account, so it never leaks
        existence. A first-time address gets an account on its first successful
        verification, so this is both sign-up and log-in. Issuing a code
        invalidates any previous one for the same address.
      operationId: requestSignInCode
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SignInCodeRequest'
      responses:
        '200':
          description: If the address can receive a code, one has been sent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Acknowledgement'
        '400':
          $ref: '#/components/responses/BadRequest'
  /v1/auth/code/verify:
    post:
      tags: [Auth]
      summary: Redeem a sign-in code
      description: >
        Exchanges an emailed code for a session, creating the account if the
        address is new. The code is single-use and short-lived. A code is
        destroyed after too many wrong guesses, since six digits is small enough
        to guess given unlimited attempts — the client must then request a new
        one rather than retry.
      operationId: verifySignInCode
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SignInCodeVerify'
      responses:
        '200':
          description: Authenticated; a session is returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: >
            The code is wrong, expired, or already used (`invalid_token` /
            `expired_token`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: >
            Too many incorrect attempts (`too_many_attempts`); the code has been
            destroyed and a new one must be requested.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/auth/google:
    post:
      tags: [Auth]
      summary: Sign in with Google
      description: >
        Exchanges a Google ID token, issued to this deployment's web client, for
        a session. The account is the one for the token's address: an address
        that signed in with an emailed code reaches the same account, and a new
        address gets one. Google must have verified the address.
      operationId: signInWithGoogle
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GoogleSignIn'
      responses:
        '200':
          description: Authenticated; a session is returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: >
            The token is malformed, expired, not signed by Google or issued to
            another client (`invalid_token`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Google has not verified the address (`email_not_verified`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: >
            Sign-in with Google is not configured on this deployment
            (`google_sign_in_not_configured`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/auth/refresh:
    post:
      tags: [Auth]
      summary: Refresh a session
      description: >
        Exchanges a valid refresh token for a new access JWT and a rotated
        refresh token, taken from the body or from the `refresh_token` cookie set
        at sign-in — a browser sends an empty body and the cookie carries the
        credential. Refresh tokens are single-use; presenting a
        previously-rotated token is treated as reuse and revokes the whole
        session family (createRefreshRotation reuse detection), except within a
        few seconds of the rotation, where it is treated as two tabs racing on
        one cookie and rotated again.
      operationId: refreshSession
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefreshRequest'
      responses:
        '200':
          description: A new session (access + rotated refresh).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSession'
        '401':
          description: The refresh token is invalid, expired or was reused.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/auth/logout:
    post:
      tags: [Auth]
      summary: Log out
      description: >
        Revokes the current refresh token (and its rotation family) and clears
        the `refresh_token` cookie. Pass `all: true` to revoke every active
        session for the user.
      operationId: logout
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LogoutRequest'
      responses:
        '204':
          description: Session(s) revoked.
        '401':
          $ref: '#/components/responses/Unauthorized'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A naturali API key (nat_sk_…) or a session JWT.
    oauth2:
      type: oauth2
      description: >-
        A connected app's OAuth access token, issued by this API's authorization
        server (discovery: /.well-known/oauth-authorization-server). Its one scope
        carries every operation, confined to the projects the user chose when
        approving the app.
      flows:
        authorizationCode:
          authorizationUrl: https://api.naturali.ai/authorize
          tokenUrl: https://api.naturali.ai/token
          refreshUrl: https://api.naturali.ai/token
          scopes:
            mcp:access: Every operation this API serves, on the projects the grant covers.
  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    SignInCodeRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
          example: ana@acme.com
    SignInCodeVerify:
      type: object
      required: [email, code]
      properties:
        email:
          type: string
          format: email
          example: ana@acme.com
        code:
          type: string
          description: The code from the email, as sent — digits only.
          pattern: '^[0-9]{4,12}$'
          example: '481902'
    GoogleSignIn:
      type: object
      required: [id_token]
      properties:
        id_token:
          type: string
          description: The `credential` Google Identity Services returns to the page.
          example: eyJhbGciOiJSUzI1NiIs...
    Acknowledgement:
      type: object
      description: >
        A deliberately uninformative confirmation. The wording is identical for a
        known and an unknown address so the response cannot be used to enumerate
        accounts.
      properties:
        message:
          type: string
          example: If the address is registered, an email has been sent.
    AuthSession:
      type: object
      description: >
        A short-lived access JWT plus a rotating refresh token. The refresh token
        is also set as an httpOnly, SameSite=Lax cookie scoped to `/v1/auth`
        (Secure whenever the connection is https), which is what carries a
        browser session across a reload; the body copy is for clients with no
        cookie jar, such as the CLI.
      properties:
        token_type:
          type: string
          enum: [Bearer]
          example: Bearer
        access_token:
          type: string
          description: 'Short-lived access JWT. Send as `Authorization: Bearer <token>`.'
          example: eyJhbGciOiJIUzI1NiJ9...
        expires_in:
          type: integer
          description: Access-token lifetime in seconds.
          example: 900
        refresh_token:
          type: string
          description: Single-use refresh token, rotated on every refresh.
          example: rt_9f2StGXR8Z5jdHi6B...
        user:
          $ref: '#/components/schemas/User'
      required: [token_type, access_token, expires_in, user]
    RefreshRequest:
      type: object
      description: >
        Send an empty object from a browser — the httpOnly `refresh_token` cookie
        is presented instead, and takes over when the body omits the field.
      properties:
        refresh_token:
          type: string
          example: rt_9f2StGXR8Z5jdHi6B...
    LogoutRequest:
      type: object
      properties:
        refresh_token:
          type: string
          description: The refresh token to revoke; defaults to the current session's.
          example: rt_9f2StGXR8Z5jdHi6B...
        all:
          type: boolean
          default: false
          description: Revoke every active session for the user.
    User:
      type: object
      properties:
        id:
          type: string
          description: Public user ID (user_ prefix).
          example: user_V1StGXR8Z5jdHi6B
        email:
          type: string
          format: email
          example: ana@acme.com
        name:
          type: string
          nullable: true
          example: Ana Silva
        email_verified:
          type: boolean
          example: true
        roles:
          type: array
          description: >-
            Roles on naturali itself, not on a project. Empty for almost every
            account; `admin` marks a naturali operator. Changed only through
            `PUT /v1/admin/users/{user_id}/roles`, by a caller holding the role
            that grants each one.
          items:
            type: string
            enum: [admin]
          example: []
        usage_alerts:
          type: boolean
          description: >-
            Whether the account is emailed when its model credit, runs or
            indexed storage reaches 75%, 90% and 100%. On for a new account.
          example: true
        created_at:
          type: string
          format: date-time
          example: '2026-07-17T00:00:00.000Z'
      required: [id, email, email_verified, roles, usage_alerts, created_at]
    ErrorResponse:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          description: >-
            Structured error. Every error response uses this shape, so `code` can
            be read without first checking the type of `error`.
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: >-
                Machine-readable error code: lower_snake for an error naturali
                raises (`access_denied`), UPPER_SNAKE for one the runtime reports
                (`RESOURCE_NOT_FOUND`).
              example: access_denied
            message:
              type: string
              description: Human-readable explanation.
              example: Your role in this project does not carry this action.
            details:
              type: object
              additionalProperties: true
              description: >-
                Structured context for an error naturali raises, such as the
                `resource` and `limit` of a `plan_limit_reached`.
            meta:
              type: object
              description: Structured context for an error the runtime reports.
