openapi: 3.1.0

info:
  title: turboEnv API
  version: 0.1.0
  description: |
    Fetch environment variables into your application at startup.

    This is the public, token-authenticated surface of turboEnv, and it is the
    source of truth for every generated SDK. Dashboard operations authenticate
    with a Clerk browser session and are not part of this contract, because a
    session cookie is not something a server-side SDK can hold.

    ## Almost entirely read-only

    Two operations change anything: `POST /v1/env` saves a version, and
    `DELETE /v1/projects/{slug}` destroys a project and everything under it.
    Both are restricted to credentials issued by `turboenv login` — a person, on
    their own machine. A token created in the dashboard cannot call either and
    receives a `403`.

    That restriction is the point rather than an oversight. Tokens are what sit
    in CI, in container images and in an agent's configuration, and keeping them
    read-only means a leaked one exposes configuration without being able to
    change what the next deployment reads, or to delete it.

    ## Reading without handling secrets

    Two operations read an environment, and the difference matters when the
    caller is an AI agent or anything else that logs what it receives.

    `GET /v1/variables` returns variable *names* and whether each is secret. It
    never decrypts anything. Most automated callers only need to know what an
    application expects, and this answers that without moving a secret.

    `GET /v1/env` returns the decrypted values. Send them to a process or a
    file. Putting them in a language model's context window means handing them
    to whoever can read that conversation later.

servers:
  - url: https://app.turboenv.workers.dev/api
    description: Hosted
  - url: http://localhost:5173/api
    description: Local development

security:
  - bearerAuth: []

tags:
  # "Variables" rather than "Environment", because generators routinely emit a
  # module of their own by that name — Python's in particular — and a resource
  # that collides with it breaks the generated client at import time.
  - name: Project
    description: Discover what a token can reach.
  - name: Variables
    description: Read environment variables, with or without their values.
  - name: Versions
    description: Inspect the version history available to this token.
  - name: Login
    description: |
      Obtain a credential for a developer's own machine, by approving it in a
      browser. Not for servers or CI, which should use a project token.

paths:
  /v1/project:
    get:
      operationId: describe
      tags: [Project]
      summary: Describe what this token can reach
      description: |
        The project this token belongs to and the environments it may read,
        each with the version a reader would currently get. Start here when all
        you hold is a token: it tells you which values `environment` accepts.

        An environment-scoped token sees only its own environment. A login
        credential covers a whole team, so it must name which project it means.
      parameters:
        - $ref: "#/components/parameters/Project"
      responses:
        "200":
          description: The token's project and reachable environments.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectScope"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/projects:
    get:
      operationId: listProjects
      tags: [Project]
      summary: List the projects this token can reach
      description: |
        Every project in the team, for a credential from `turboenv login`. A
        dashboard token is scoped to one project and sees only that one, so this
        returns a single entry rather than an error.

        Intended for building a picker, which is what `turboenv init` does with
        it. To learn an individual project's environments, use `GET /v1/project`.
      responses:
        "200":
          description: The projects this token can reach, sorted by slug.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectList"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{slug}:
    delete:
      operationId: deleteProject
      tags: [Project]
      summary: Delete a project and everything in it
      description: |
        Permanently removes the project, its environments, every version and
        variable in them, and every token issued for it. There is no undo, and
        anything deploying with one of those tokens stops being able to read its
        configuration immediately.

        Restricted to credentials from `turboenv login`, for the same reason
        writing is: a token sitting in CI or in an agent's MCP configuration
        must not be able to destroy what it was given to read. A project token
        is refused with `403`.
      parameters:
        - name: slug
          in: path
          required: true
          description: The project's slug, as returned by `GET /v1/projects`.
          schema:
            type: string
      responses:
        "200":
          description: What the deletion removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Destroyed"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/variables:
    get:
      operationId: names
      tags: [Variables]
      summary: List variable names without their values
      description: |
        Which variables an environment defines and which are marked secret. No
        value is decrypted, so nothing sensitive can leak through this response.

        Prefer this over `GET /v1/env` whenever the answer you need is "what
        does this application expect to be configured" rather than "what are the
        values".
      parameters:
        - $ref: "#/components/parameters/Project"
        - $ref: "#/components/parameters/Environment"
        - $ref: "#/components/parameters/Version"
      responses:
        "200":
          description: The variables defined in this environment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VariableManifest"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/env:
    get:
      operationId: get
      tags: [Variables]
      summary: Fetch environment variables
      description: |
        Returns the resolved variables, decrypted. Defaults to the latest
        version; pass `version` to pin a specific one, which is the recommended
        setting for production deployments that want reproducible startups.

        These are live credentials. Load them into a process or write them to a
        file. If a language model is making this call, prefer
        `GET /v1/variables`, which answers most questions without the values.
      parameters:
        - $ref: "#/components/parameters/Project"
        - $ref: "#/components/parameters/Environment"
        - $ref: "#/components/parameters/Version"
      responses:
        "200":
          description: The resolved environment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Environment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The project requires client-side encryption; use `/v1/ciphertext`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      operationId: save
      tags: [Variables]
      summary: Save a new version of an environment
      description: |
        Replaces the environment's contents with the variables you send and
        returns the new version number. Nothing is edited in place: previous
        versions stay readable, so a bad save is rolled back by pinning the
        version before it.

        ## Only a person can call this

        Requires a credential from `turboenv login`. A token created in the
        dashboard is read-only and receives a `403`, so a token leaked from CI
        cannot alter what the next deployment reads.

        ## Send the version you started from

        `base_version` is the version you built these variables from — use `0`
        for an environment with no versions yet. If somebody saved in the
        meantime, the request is refused with a `409` instead of overwriting
        their work. Read the current state again, re-apply your change, and
        retry.

        ## This is the whole environment

        A variable you omit is gone from the new version. To change one value,
        read the environment first and send it back with that one edited. This
        is what `turboenv push` does, and why it refuses to remove anything
        without `--prune`.
      parameters:
        - $ref: "#/components/parameters/Project"
        - $ref: "#/components/parameters/Environment"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VersionInput"
      responses:
        "201":
          description: The version that was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SavedVersion"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            This credential may not write. Dashboard tokens are read-only; use
            one from `turboenv login`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A stale version, or a customer-managed project requiring `/v1/ciphertext`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/ciphertext:
    get:
      operationId: getCiphertext
      tags: [Variables]
      summary: Fetch a customer-managed encrypted environment
      description: |
        Returns ciphertext for a customer-managed project. The customer key and
        plaintext never reach this API; a compatible client unwraps the project
        key and decrypts values locally. Service-managed projects are rejected.
      parameters:
        - $ref: "#/components/parameters/Project"
        - $ref: "#/components/parameters/Environment"
        - $ref: "#/components/parameters/Version"
      responses:
        "200":
          description: The encrypted environment and public crypto metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CiphertextEnvironment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: saveCiphertext
      tags: [Variables]
      summary: Save a locally encrypted environment version
      description: |
        Stores a complete customer-managed snapshot without decrypting it.
        Requires a credential from `turboenv login`; project tokens remain
        read-only. Ciphertext must authenticate the intended next version.
      parameters:
        - $ref: "#/components/parameters/Project"
        - $ref: "#/components/parameters/Environment"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CiphertextVersionInput"
      responses:
        "201":
          description: The version that was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SavedVersion"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/versions:
    get:
      operationId: list
      tags: [Versions]
      summary: List versions
      description: Version history for the environment, newest first.
      parameters:
        - $ref: "#/components/parameters/Project"
        - $ref: "#/components/parameters/Environment"
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            format: int32
            minimum: 1
            maximum: 100
            default: 20
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from a previous response's `nextCursor`.
          schema:
            type: string
      responses:
        "200":
          description: A page of versions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VersionPage"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /v1/cli/request:
    post:
      operationId: startLogin
      tags: [Login]
      summary: Start a browser login
      security: []
      description: |
        Begins the `turboenv login` handshake and returns the page a person has
        to approve. Unauthenticated, because obtaining a credential is the point.

        The caller generates a PKCE verifier, keeps it, and sends only
        `code_challenge`, the base64url SHA-256 of it. It also binds a loopback
        listener first and sends the port, so the redirect target belongs to a
        request the client itself made rather than one named in a crafted URL.

        `user_code` must be displayed to the person running the command. They
        type it into the approval page, and that is what makes approval
        deliberate rather than something a page can walk somebody through.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code_challenge, redirect_port]
              properties:
                code_challenge:
                  type: string
                  description: base64url SHA-256 of the PKCE verifier.
                redirect_port:
                  type: integer
                  format: int32
                  minimum: 1
                  maximum: 65535
                  description: Port of the client's 127.0.0.1 listener.
                label:
                  type: string
                  nullable: true
                  description: |
                    What the client calls itself, usually a hostname. Shown on
                    the approval page and used to name the credential. A hint
                    only: it grants nothing.
      responses:
        "201":
          description: The request was created and is awaiting approval.
          content:
            application/json:
              schema:
                type: object
                required: [request_id, user_code, approve_path, expires_in]
                properties:
                  request_id:
                    type: string
                  user_code:
                    type: string
                    description: Show this in the terminal. Never send it anywhere.
                  approve_path:
                    type: string
                    description: Path on the web app for the person to open.
                  expires_in:
                    type: integer
                    format: int32
                    description: Seconds until the request expires.
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/cli/exchange:
    post:
      operationId: finishLogin
      tags: [Login]
      summary: Redeem an approved login for a credential
      security: []
      description: |
        Exchanges the one-time code the browser handed to the loopback listener
        for a credential, proving possession of the PKCE verifier. Single use:
        a replayed code is refused, as is a wrong verifier or an expired request.

        The credential is created at this moment and returned exactly once, so
        the plaintext is never stored anywhere. It is scoped to the team that
        approved it and reads every project that team owns, which is why the
        caller must name `project` on every subsequent request.

        Every failure answers the same way, so probing teaches nothing about
        which part was wrong.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, code_verifier]
              properties:
                code:
                  type: string
                  description: The one-time code from the loopback redirect.
                code_verifier:
                  type: string
                  description: The PKCE verifier, which never left the client until now.
      responses:
        "200":
          description: The credential, returned once and not recoverable.
          content:
            application/json:
              schema:
                type: object
                required: [token, expires_in]
                properties:
                  token:
                    type: string
                  expires_in:
                    type: integer
                    format: int32
                    description: |
                      Seconds of inactivity before it expires. Renewed on use.
        "400":
          $ref: "#/components/responses/BadRequest"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        A turboEnv credential, sent as `Authorization: Bearer tenv_live_...`.
        Shown only once, whichever way it was created.

        There are two kinds, and the difference is what they can reach.

        **Project tokens**, created in the dashboard, are fixed at creation. A
        token scoped to one environment can read exactly that environment, and
        naming a different one is refused. A token scoped to a whole project can
        read any environment in it and must say which one it means; that is what
        the MCP server uses. Neither can be pointed at another project. These
        never expire on their own and are what servers, deployments and CI
        should use.

        **Login credentials**, created by `turboenv login`, are scoped to a team
        rather than a project, so they can read every project that team owns.
        They therefore have no implicit target: `project` and `environment` are
        both required, and a project belonging to another team reads as absent
        rather than forbidden. They expire after 30 days without use, renewed on
        every request, and can be revoked from the dashboard. They are meant for
        a developer's own machine — the CLI refuses to use one when it detects
        CI.

  parameters:
    Project:
      name: project
      in: query
      required: false
      description: |
        Which project to read, by slug. Required for a login credential, which
        covers a whole team and so carries no project of its own. A project
        token may omit it, and naming a project other than its own is refused.

        A slug that belongs to another team answers 404, not 403, so this
        parameter cannot be used to discover what exists elsewhere.
      schema:
        type: string
      examples:
        checkout:
          value: checkout-service

    Environment:
      name: environment
      in: query
      required: false
      description: |
        Which environment to read. Required unless the token is scoped to a
        single environment, in which case it may be omitted and may not name a
        different one.
      schema:
        type: string
      examples:
        production:
          value: production

    Version:
      name: version
      in: query
      required: false
      description: Version number to fetch. Omit for the latest version.
      schema:
        type: integer
        format: int32
        minimum: 1

  schemas:
    ProjectEncryption:
      oneOf:
        - type: object
          required: [mode]
          properties:
            mode:
              type: string
              const: service
        - $ref: "#/components/schemas/CustomerEncryption"
      discriminator:
        propertyName: mode

    CustomerEncryption:
      type: object
      required: [mode, formatVersion, context, keyFingerprint, wrappedDek]
      properties:
        mode:
          type: string
          const: customer
        formatVersion:
          type: integer
          const: 1
        context:
          type: string
          description: Public random context authenticated by every ciphertext.
        keyFingerprint:
          type: string
          pattern: "^tenv_kfp_[A-Za-z0-9_-]{16}$"
        wrappedDek:
          $ref: "#/components/schemas/Sealed"

    Sealed:
      type: object
      required: [iv, ciphertext]
      properties:
        iv:
          type: string
          description: Unpadded base64url encoding of a 12-byte AES-GCM IV.
        ciphertext:
          type: string
          description: Unpadded base64url AES-256-GCM ciphertext including its tag.

    ProjectScope:
      type: object
      required: [project, environments, scope]
      properties:
        project:
          type: object
          required: [id, slug, name, encryption]
          properties:
            id:
              type: string
              examples: ["prj_01abc"]
            slug:
              type: string
              examples: ["checkout-service"]
            name:
              type: string
              examples: ["Checkout Service"]
            encryption:
              $ref: "#/components/schemas/ProjectEncryption"
        environments:
          type: array
          description: Environments this token may read.
          items:
            type: object
            required: [id, name, latestVersion]
            properties:
              id:
                type: string
                examples: ["env_01abc"]
              name:
                type: string
                examples: ["production"]
              latestVersion:
                type: [integer, "null"]
                format: int32
                description: Null when the environment has no saved versions yet.
                examples: [14]
        scope:
          type: object
          required: [environment]
          properties:
            environment:
              type: [string, "null"]
              description: |
                The environment this token is locked to, or null if it covers
                the whole project.
              examples: ["production"]

    ProjectList:
      type: object
      required: [projects]
      properties:
        projects:
          type: array
          items:
            type: object
            required: [slug, name, encryptionMode]
            properties:
              slug:
                type: string
                description: Use this as the `project` parameter.
                examples: ["checkout-service"]
              name:
                type: string
                examples: ["Checkout Service"]
              encryptionMode:
                type: string
                enum: [service, customer]

    Destroyed:
      type: object
      required: [destroyed]
      properties:
        destroyed:
          type: object
          description: |
            Counts of what the deletion removed, so a caller can report the size
            of what just happened rather than guess at it.
          required: [environments, versions, variables, tokens]
          properties:
            environments:
              type: integer
              format: int32
              examples: [3]
            versions:
              type: integer
              format: int32
              examples: [27]
            variables:
              type: integer
              format: int32
              examples: [104]
            tokens:
              type: integer
              format: int32
              description: Active tokens that stopped working.
              examples: [2]

    VersionInput:
      type: object
      required: [base_version, variables]
      properties:
        base_version:
          type: integer
          format: int32
          minimum: 0
          description: |
            The version these variables were built from, or `0` if the
            environment has none yet. A mismatch is a `409`.
          examples: [14]
        message:
          type: [string, "null"]
          maxLength: 200
          description: Why this changed, shown in the version history.
          examples: ["rotate Stripe key"]
        variables:
          type: array
          minItems: 1
          description: |
            The complete contents of the new version. Anything omitted will not
            exist in it.
          items:
            type: object
            required: [key, value]
            properties:
              key:
                type: string
                pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
                examples: ["DATABASE_URL"]
              value:
                type: string
                examples: ["postgres://localhost:5432/app"]
              isSecret:
                type: boolean
                default: true
                description: |
                  Whether to treat this as sensitive. Everything is encrypted at
                  rest either way; this hides it from the dashboard and from
                  agent-facing reads. Defaults to true, so a value is protected
                  unless you say otherwise.
                examples: [true]

    EncryptedVariable:
      allOf:
        - $ref: "#/components/schemas/Sealed"
        - type: object
          required: [key, isSecret]
          properties:
            key:
              type: string
              pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
              examples: ["DATABASE_URL"]
            isSecret:
              type: boolean

    CiphertextVersionInput:
      type: object
      required: [base_version, variables]
      properties:
        base_version:
          type: integer
          format: int32
          minimum: 0
          description: The version these ciphertexts were built from.
        message:
          type: [string, "null"]
          maxLength: 200
        variables:
          type: array
          minItems: 1
          maxItems: 500
          description: |
            The complete encrypted snapshot. Values must authenticate
            `base_version + 1` according to crypto format version 1.
          items:
            $ref: "#/components/schemas/EncryptedVariable"

    CiphertextEnvironment:
      type: object
      required: [project, environment, version, createdAt, encryption, variables]
      properties:
        project:
          type: object
          required: [id, slug]
          properties:
            id:
              type: string
            slug:
              type: string
        environment:
          type: object
          required: [id, name]
          properties:
            id:
              type: string
            name:
              type: string
        version:
          type: integer
          format: int32
        createdAt:
          type: string
          format: date-time
        encryption:
          $ref: "#/components/schemas/CustomerEncryption"
        variables:
          type: array
          items:
            $ref: "#/components/schemas/EncryptedVariable"

    SavedVersion:
      type: object
      required: [version]
      description: |
        Only the new version number. The project and environment are not echoed
        because the caller just named them, and reading them back would cost a
        second query on the write path to tell you what you already know.
      properties:
        version:
          type: integer
          format: int32
          description: Pass this as the next `base_version`.
          examples: [15]

    VariableManifest:
      type: object
      required: [project, environment, version, createdAt, variables]
      properties:
        project:
          type: string
          examples: ["checkout-service"]
        environment:
          type: string
          examples: ["production"]
        version:
          type: integer
          format: int32
          examples: [14]
        createdAt:
          type: string
          format: date-time
        variables:
          type: array
          description: Defined variables, sorted by name. Values are not included.
          items:
            type: object
            required: [key, isSecret]
            properties:
              key:
                type: string
                examples: ["DATABASE_URL"]
              isSecret:
                type: boolean
                description: |
                  Whether this value is treated as sensitive. Every value is
                  encrypted at rest regardless; this marks the ones that should
                  not be displayed, logged, or shown to a language model.
                examples: [true]

    Environment:
      type: object
      required: [project, environment, version, createdAt, variables]
      properties:
        project:
          type: string
          description: Project slug.
          examples: ["checkout-service"]
        environment:
          type: string
          description: Environment name.
          examples: ["production"]
        version:
          type: integer
          format: int32
          description: The version number these variables came from.
          examples: [14]
        createdAt:
          type: string
          format: date-time
          description: When this version was saved.
        variables:
          type: object
          description: |
            Variable names mapped to their plaintext values, decrypted server
            side. Key order is not significant.
          additionalProperties:
            type: string
          examples:
            - DATABASE_URL: postgres://localhost:5432/app
              STRIPE_KEY: sk_live_abc123

    VersionPage:
      type: object
      required: [versions]
      properties:
        versions:
          type: array
          items:
            $ref: "#/components/schemas/VersionSummary"
        nextCursor:
          type: [string, "null"]
          description: Pass as `cursor` to fetch the next page. Null on the last page.

    VersionSummary:
      type: object
      required: [version, createdAt, variableCount]
      properties:
        version:
          type: integer
          format: int32
          examples: [14]
        message:
          type: [string, "null"]
          description: Optional note recorded when the version was saved.
          examples: ["rotate stripe key"]
        createdAt:
          type: string
          format: date-time
        variableCount:
          type: integer
          format: int32
          examples: [12]

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable, machine-readable error code.
              examples: ["invalid_token"]
            message:
              type: string
              description: Human-readable explanation. Do not match on this.

  responses:
    BadRequest:
      description: The request is malformed, or omits a required parameter.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: The token is missing, malformed, revoked, or expired.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: The token is valid but not scoped to the requested resource.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: No such version, or the environment has no versions yet.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: |
        Somebody saved since `base_version`. Read the environment again,
        re-apply your change, and retry.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Too many requests.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
