openapi: 3.1.0
info:
  title: GameServersPanel Partner API
  version: "1.0"
  description: |
    Manage ARK game servers programmatically: discover servers, read live
    status, read and deploy configuration files, control power, manage launch
    options, and restore backups.

    Authentication is a bearer token (static `gsp_live_...` or OAuth access
    `gsp_at_...`). The two `/oauth/*` operations are not bearer-secured; they use
    form-encoded bodies and authenticate by client_id plus a PKCE proof or
    refresh token.

    Required scopes per operation are stated in each operation description.
  contact:
    name: GameServersPanel Support
    email: support@gameservershub.com
servers:
  - url: https://gameserverspanel.com/api/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Discovery
    description: Capabilities and token identity.
  - name: Servers
    description: List servers and read live status.
  - name: Power
    description: Start, stop, restart, and track jobs.
  - name: Config
    description: Read, deploy, and back up configuration files.
  - name: Startup
    description: Read and update structured launch options.
  - name: OAuth
    description: Token exchange and revocation for public PKCE clients.

paths:
  /capabilities:
    get:
      tags: [Discovery]
      operationId: getCapabilities
      summary: Supported features and OAuth endpoints
      description: Requires any valid token (no specific scope).
      responses:
        "200":
          description: Capability document.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Capabilities" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /me:
    get:
      tags: [Discovery]
      operationId: getMe
      summary: Token identity and reach
      description: Requires any valid token (no specific scope).
      responses:
        "200":
          description: Token identity.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /servers:
    get:
      tags: [Servers]
      operationId: listServers
      summary: List reachable servers
      description: "Required scope: servers:read."
      parameters:
        - name: game
          in: query
          required: false
          description: Filter by short code.
          schema:
            type: string
            enum: [arksa, arkse]
      responses:
        "200":
          description: Servers the token can reach.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Server" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /servers/{serverId}:
    get:
      tags: [Servers]
      operationId: getServer
      summary: Get one server
      description: "Required scope: servers:read."
      parameters:
        - $ref: "#/components/parameters/ServerId"
      responses:
        "200":
          description: One server.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Server" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /servers/{serverId}/status:
    get:
      tags: [Servers]
      operationId: getServerStatus
      summary: Live state and resource snapshot
      description: "Required scope: servers:read."
      parameters:
        - $ref: "#/components/parameters/ServerId"
      responses:
        "200":
          description: Live status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServerStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /servers/{serverId}/power/{action}:
    post:
      tags: [Power]
      operationId: powerAction
      summary: Start, stop, or restart a server
      description: "Required scope: power:write. Asynchronous; returns a job id."
      parameters:
        - $ref: "#/components/parameters/ServerId"
        - name: action
          in: path
          required: true
          schema:
            type: string
            enum: [start, stop, restart]
      responses:
        "202":
          description: Action queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PowerJobAccepted" }
        "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" }

  /servers/{serverId}/config-files:
    get:
      tags: [Config]
      operationId: listConfigFiles
      summary: List exposed config files
      description: "Required scope: files:read."
      parameters:
        - $ref: "#/components/parameters/ServerId"
      responses:
        "200":
          description: Config file descriptors.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ConfigFileDescriptor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /servers/{serverId}/config-files/{key}:
    get:
      tags: [Config]
      operationId: getConfigFile
      summary: Download a config file
      description: "Required scope: files:read. Returns content plus an ETag."
      parameters:
        - $ref: "#/components/parameters/ServerId"
        - $ref: "#/components/parameters/ConfigKey"
      responses:
        "200":
          description: File content.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConfigFile" }
        "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" }
    put:
      tags: [Config]
      operationId: putConfigFile
      summary: Upload one config file
      description: >
        Required scope: files:write (plus power:write when restartPolicy is not
        manual). ETag-guarded via the If-Match header or a body etag; a backup is
        taken before the write.
      parameters:
        - $ref: "#/components/parameters/ServerId"
        - $ref: "#/components/parameters/ConfigKey"
        - name: If-Match
          in: header
          required: false
          description: Expected current ETag; takes precedence over the body etag.
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PutConfigFileRequest" }
      responses:
        "200":
          description: Deployment result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConfigDeployment" }
        "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" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/WriteFailed" }

  /servers/{serverId}/config-deployments:
    post:
      tags: [Config]
      operationId: createConfigDeployment
      summary: Deploy multiple config files atomically
      description: >
        Required scope: files:write (plus power:write when restartPolicy is not
        manual). Validates, backs up, and writes all files, then optionally
        restarts.
      parameters:
        - $ref: "#/components/parameters/ServerId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConfigDeploymentRequest" }
      responses:
        "200":
          description: Deployment result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConfigDeployment" }
        "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" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/WriteFailed" }

  /servers/{serverId}/startup:
    get:
      tags: [Startup]
      operationId: getStartup
      summary: Read structured launch options
      description: "Required scope: startup:read."
      parameters:
        - $ref: "#/components/parameters/ServerId"
      responses:
        "200":
          description: Launch options.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StartupOptions" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Startup]
      operationId: updateStartup
      summary: Update launch options (partial)
      description: >
        Required scope: startup:write (plus power:write when restartPolicy is not
        manual). Only the fields you send are changed.
      parameters:
        - $ref: "#/components/parameters/ServerId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StartupUpdateRequest" }
      responses:
        "200":
          description: Update result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StartupUpdateResult" }
        "400":
          description: Validation failed (may include fieldErrors).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /servers/{serverId}/backups:
    get:
      tags: [Config]
      operationId: listBackups
      summary: List config backups
      description: "Required scope: files:read."
      parameters:
        - $ref: "#/components/parameters/ServerId"
      responses:
        "200":
          description: Backup snapshots.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Backup" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /servers/{serverId}/backups/{backupId}/restore:
    post:
      tags: [Config]
      operationId: restoreBackup
      summary: Restore a config backup
      description: >
        Required scope: backups:write (plus power:write when restartPolicy is not
        manual). Takes a fresh backup before overwriting, so it is reversible.
      parameters:
        - $ref: "#/components/parameters/ServerId"
        - name: backupId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RestoreRequest" }
      responses:
        "200":
          description: Restore result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConfigDeployment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/WriteFailed" }

  /jobs/{jobId}:
    get:
      tags: [Power]
      operationId: getJob
      summary: Get job status
      description: "Required scope: servers:read."
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Job status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /oauth/token:
    post:
      tags: [OAuth]
      operationId: oauthToken
      summary: Exchange a code or rotate a refresh token
      description: >
        Public PKCE client flow, no client secret. Not bearer-secured.
        Form-encoded body, 30 requests/minute per source address.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
      responses:
        "200":
          description: Token pair.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthTokenResponse" }
        "400":
          description: invalid_request, invalid_grant, or unsupported_grant_type.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "401":
          description: Unknown client_id (invalid_client).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "429":
          description: Rate limited.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /oauth/revoke:
    post:
      tags: [OAuth]
      operationId: oauthRevoke
      summary: Revoke a token and its grant
      description: >
        RFC 7009. Always returns 200 (even for an unknown token). Not
        bearer-secured. Form-encoded body, 30 requests/minute per source address.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthRevokeRequest" }
      responses:
        "200":
          description: Revoked (empty body); also returned for an unknown token.
        "400":
          description: invalid_request.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "401":
          description: Unknown client_id (invalid_client).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "429":
          description: Rate limited.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        A static token (gsp_live_...) or an OAuth access token (gsp_at_...) in
        the Authorization header.

  parameters:
    ServerId:
      name: serverId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ConfigKey:
      name: key
      in: path
      required: true
      description: Logical config key, for example game_ini.
      schema:
        type: string
        examples: [game_ini, game_user_settings_ini]

  headers:
    RateLimitLimit:
      description: Requests allowed in the current window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Unix epoch seconds when the window resets.
      schema: { type: integer }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer }

  responses:
    BadRequest:
      description: Validation, encoding, or unknown-key error.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Insufficient scope, denied permission, or disallowed IP.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Unknown id, or outside the token's reach.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Version conflict, server busy, or machine offline.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: Request body exceeded the cap.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    WriteFailed:
      description: A multi-file write partially failed; backup ids are in the message.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Capabilities:
      type: object
      required: [apiVersion, games, features, oauth]
      properties:
        apiVersion: { type: string, examples: ["1.0"] }
        games:
          type: array
          items: { type: string, enum: [arksa, arkse] }
        features:
          type: object
          properties:
            oauthPkce: { type: boolean }
            staticTokens: { type: boolean }
            powerControl: { type: boolean }
            configDeployments: { type: boolean }
            backups: { type: boolean }
            launchOptions: { type: boolean }
        oauth:
          type: object
          properties:
            authorizationEndpoint: { type: string }
            tokenEndpoint: { type: string }
            revocationEndpoint: { type: string }
            codeChallengeMethodsSupported:
              type: array
              items: { type: string }
            grantTypesSupported:
              type: array
              items: { type: string }

    Me:
      type: object
      required: [tokenId, type, scopes, serverScope, servers]
      properties:
        tokenId: { type: string, format: uuid }
        type:
          type: string
          enum: [api_token, oauth_access_token]
        scopes:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
        serverScope:
          type: string
          enum: [restricted, all]
        servers: { type: integer }

    Scope:
      type: string
      enum:
        - servers:read
        - files:read
        - files:write
        - backups:write
        - power:write
        - startup:read
        - startup:write

    Server:
      type: object
      required: [id, name, game, status, machine, supports]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        game:
          type: string
          enum: [arksa, arkse]
        status: { $ref: "#/components/schemas/ServerStatusEnum" }
        machine:
          type: object
          required: [id, online]
          properties:
            id: { type: string, format: uuid }
            online: { type: boolean }
        supports:
          type: object
          required: [configFiles, powerControl, launchOptions, backups]
          properties:
            configFiles: { type: boolean }
            powerControl: { type: boolean }
            launchOptions: { type: boolean }
            backups: { type: boolean }

    ServerStatusEnum:
      type: string
      enum:
        - installing
        - starting
        - running
        - stopping
        - stopped
        - crashed
        - updating
        - unknown

    ServerStatus:
      type: object
      required: [serverId, status, agentOnline, cpuPercent, memoryMb, pids, uptimeSeconds]
      properties:
        serverId: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/ServerStatusEnum" }
        agentOnline: { type: boolean }
        cpuPercent:
          type: [number, "null"]
        memoryMb:
          type: [number, "null"]
        pids:
          type: [integer, "null"]
        uptimeSeconds:
          type: [integer, "null"]

    PowerJobAccepted:
      type: object
      required: [jobId, status, targetState]
      properties:
        jobId: { type: string, format: uuid }
        status:
          type: string
          enum: [queued]
        targetState:
          type: string
          enum: [running, stopped]

    ConfigFileDescriptor:
      type: object
      required: [key, displayName, readable, writable, contentType]
      properties:
        key: { type: string, examples: [game_ini] }
        displayName: { type: string, examples: ["Game.ini"] }
        readable: { type: boolean }
        writable: { type: boolean }
        contentType: { type: string, examples: ["text/plain"] }

    ConfigFile:
      type: object
      required: [serverId, fileKey, filename, content, encoding, etag]
      properties:
        serverId: { type: string, format: uuid }
        fileKey: { type: string }
        filename: { type: string }
        content: { type: string }
        encoding:
          type: string
          enum: ["utf-8"]
        etag:
          type: string
          description: cfg_ prefix plus a 32-hex content hash.
          examples: ["cfg_7b9e1d0a2c4f6b8d0e1a2c3d4f5a6b7c"]

    RestartPolicy:
      type: string
      enum: [manual, restart_if_running, restart_always]
      default: manual

    PutConfigFileRequest:
      type: object
      required: [content]
      properties:
        content:
          type: string
          maxLength: 5242880
        etag:
          type: string
          maxLength: 128
        restartPolicy: { $ref: "#/components/schemas/RestartPolicy" }
        reason:
          type: string
          maxLength: 200

    ConfigDeploymentRequest:
      type: object
      required: [files]
      properties:
        files:
          type: array
          minItems: 1
          maxItems: 10
          items:
            type: object
            required: [fileKey, content]
            properties:
              fileKey:
                type: string
                maxLength: 64
              content:
                type: string
                maxLength: 5242880
              etag:
                type: string
                maxLength: 128
        restartPolicy: { $ref: "#/components/schemas/RestartPolicy" }
        reason:
          type: string
          maxLength: 200
          description: Backup label; printable text only.
        notes:
          type: string
          maxLength: 200
          description: Alias for reason.

    ConfigDeployment:
      type: object
      required: [status, filesUpdated, backupIds, restart]
      properties:
        status:
          type: string
          enum: [completed]
        filesUpdated:
          type: array
          items: { type: string }
        backupIds:
          type: array
          items: { type: string, format: uuid }
        restart:
          oneOf:
            - type: "null"
            - type: object
              required: [jobId]
              properties:
                jobId: { type: string, format: uuid }

    Backup:
      type: object
      required: [id, type, fileKey, reason, createdAt]
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          enum: [config]
        fileKey: { type: string }
        reason:
          type: [string, "null"]
        createdAt:
          type: string
          format: date-time

    RestoreRequest:
      type: object
      properties:
        restartPolicy: { $ref: "#/components/schemas/RestartPolicy" }

    StartupVariable:
      type: object
      required: [key, label, type]
      properties:
        key: { type: string }
        label: { type: string }
        type:
          type: string
          enum: [string, number, bool, enum, secret]
        value:
          description: Present for non-secret fields; null when unset.
        set:
          type: boolean
          description: Present only for secret fields (true when a value exists).
        options:
          type: array
          items: { type: string }
          description: Present for enum fields.

    StartupOption:
      type: object
      required: [key, label, type, group]
      properties:
        key: { type: string }
        label: { type: string }
        type: { type: string }
        group: { type: string }
        value: {}
        options:
          type: array
          items: {}

    StartupOptions:
      type: object
      required: [serverId, variables, options, extraArgs, rawPreview, requiresRestart]
      properties:
        serverId: { type: string, format: uuid }
        variables:
          type: array
          items: { $ref: "#/components/schemas/StartupVariable" }
        options:
          type: array
          items: { $ref: "#/components/schemas/StartupOption" }
        extraArgs: { type: string }
        rawPreview:
          type: string
          description: Read-only assembled command with secrets masked.
        requiresRestart:
          type: boolean
          enum: [true]

    StartupUpdateRequest:
      type: object
      properties:
        variables:
          type: object
          additionalProperties:
            oneOf:
              - { type: string }
              - { type: number }
              - { type: boolean }
        options:
          type: object
          additionalProperties:
            oneOf:
              - { type: string }
              - { type: number }
              - { type: boolean }
        extraArgs:
          type: string
          maxLength: 2000
        restartPolicy: { $ref: "#/components/schemas/RestartPolicy" }

    StartupUpdateResult:
      type: object
      required: [status, requiresRestart, restart]
      properties:
        status:
          type: string
          enum: [updated]
        requiresRestart:
          type: boolean
          enum: [true]
        restart:
          oneOf:
            - type: "null"
            - type: object
              required: [jobId]
              properties:
                jobId: { type: string, format: uuid }

    Job:
      type: object
      required: [id, status, serverId, action, createdAt, completedAt]
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, running, completed, failed, expired]
        serverId: { type: string, format: uuid }
        action:
          type: string
          enum: [start, stop, restart, maintenance]
        createdAt:
          type: string
          format: date-time
        completedAt:
          type: [string, "null"]
          format: date-time

    OAuthTokenRequest:
      type: object
      required: [grant_type, client_id]
      properties:
        grant_type:
          type: string
          enum: [authorization_code, refresh_token]
        client_id: { type: string }
        code:
          type: string
          description: Required for authorization_code.
        redirect_uri:
          type: string
          description: Required for authorization_code; must match /oauth/authorize.
        code_verifier:
          type: string
          description: Required for authorization_code (PKCE).
        refresh_token:
          type: string
          description: Required for refresh_token.

    OAuthTokenResponse:
      type: object
      required: [access_token, token_type, expires_in, refresh_token, scope]
      properties:
        access_token:
          type: string
          examples: ["gsp_at_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          examples: [900]
        refresh_token:
          type: string
          examples: ["gsp_rt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]
        scope:
          type: string
          description: Space-delimited granted scopes.

    OAuthRevokeRequest:
      type: object
      required: [token, client_id]
      properties:
        token:
          type: string
          description: An access (gsp_at_) or refresh (gsp_rt_) token.
        client_id: { type: string }

    OAuthError:
      type: object
      required: [error, error_description]
      properties:
        error:
          type: string
          enum:
            - invalid_request
            - invalid_client
            - invalid_grant
            - unsupported_grant_type
            - invalid_scope
            - rate_limited
        error_description: { type: string }

    ValidationError:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          enum: [validation_failed, permission_denied, insufficient_scope]
        message: { type: string }
        fieldErrors:
          type: object
          additionalProperties: { type: string }

    Error:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          description: Machine-readable code; branch on this, not the message.
          examples:
            - missing_token
            - invalid_token
            - insufficient_scope
            - ip_not_allowed
            - permission_denied
            - not_found
            - validation_failed
            - invalid_file_key
            - invalid_encoding
            - config_conflict
            - server_busy
            - server_offline
            - not_readable
            - payload_too_large
            - write_failed
            - rate_limited
        message: { type: string }
