openapi: 3.1.0
info:
  title: Sencho API
  version: 0.25.3
  description: |
    REST API for Sencho, a self-hosted Docker Compose management dashboard.

    Sencho exposes a public API for automating stack deployments, managing webhooks,
    monitoring fleet health, and integrating with CI/CD pipelines.

    ## Authentication

    All authenticated endpoints accept a Bearer token in the `Authorization` header.
    Generate API tokens from **Settings > API Tokens** in the Sencho dashboard (requires Admiral license).

    ```
    Authorization: Bearer YOUR_API_TOKEN
    ```

    ## Node Routing

    Sencho manages multiple nodes (local and remote). To target a specific node,
    include the `x-node-id` header or `?nodeId=` query parameter. If omitted,
    the request targets the default node.

    ## License Tiers

    Some endpoints require a Skipper or Admiral license. Requests to gated endpoints
    on Community Edition return `403` with `code: "PAID_REQUIRED"` or `code: "ADMIRAL_REQUIRED"`.
  contact:
    name: Sencho
    url: https://sencho.io
  license:
    name: BSL 1.1
    url: https://github.com/studio-saelix/sencho/blob/main/LICENSE

servers:
  - url: "{protocol}://{host}:{port}"
    description: Your Sencho instance
    variables:
      protocol:
        default: https
        enum: [http, https]
      host:
        default: localhost
      port:
        default: "1852"

security:
  - bearerAuth: []

tags:
  - name: Health
    description: Instance health check
  - name: Stacks
    description: Create, read, update, delete, and operate Docker Compose stacks
  - name: Containers
    description: List and manage running containers
  - name: API Tokens
    description: Manage scoped API tokens (Admiral license required)
  - name: Webhooks
    description: Configure and trigger deployment webhooks (Skipper or Admiral license required)
  - name: Nodes
    description: Manage local and remote Sencho nodes
  - name: Fleet
    description: Multi-node fleet overview and snapshots (Skipper or Admiral license required)
  - name: Scheduled Tasks
    description: Configure recurring automated operations (Admiral license required)
  - name: Registries
    description: Manage private container registry credentials (Admiral license required). These endpoints are only accessible via browser sessions — API tokens receive `SCOPE_DENIED`.
  - name: Image Updates
    description: Check for available container image updates across stacks

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: API token generated from Settings > API Tokens. Scopes — `read-only`, `deploy-only`, or `full-admin`.
    webhookSignature:
      type: apiKey
      in: header
      name: X-Webhook-Signature
      description: HMAC-SHA256 signature for webhook trigger requests. Compute as `sha256=HMAC(request_body, webhook_secret)`.

  parameters:
    stackName:
      name: stackName
      in: path
      required: true
      description: >
        Stack directory name. Must match `^[a-zA-Z0-9_-]+$` (alphanumeric characters,
        hyphens, and underscores only). Returns `400 Invalid stack name` if the name
        contains path separators, dots, spaces, or other special characters.
      schema:
        type: string
        pattern: '^[a-zA-Z0-9_-]+$'
        example: my-stack
    nodeId:
      name: x-node-id
      in: header
      required: false
      description: Target a specific node by ID. If omitted, the default node is used. Can also be passed as `?nodeId=` query parameter.
      schema:
        type: integer
        example: 1
    nodeIdQuery:
      name: nodeId
      in: query
      required: false
      description: Target a specific node by ID (alternative to `x-node-id` header).
      schema:
        type: integer
        example: 1
    idPath:
      name: id
      in: path
      required: true
      description: Numeric resource ID.
      schema:
        type: integer

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message.
        code:
          type: string
          description: Machine-readable error code (e.g., `PAID_REQUIRED`, `SCOPE_DENIED`).
          enum: [PAID_REQUIRED, ADMIRAL_REQUIRED, SCOPE_DENIED]

    SuccessMessage:
      type: object
      required: [message]
      properties:
        message:
          type: string

    SuccessBoolean:
      type: object
      required: [success]
      properties:
        success:
          type: boolean
          example: true

    Container:
      type: object
      properties:
        Id:
          type: string
          description: Docker container ID.
        Names:
          type: array
          items:
            type: string
          description: Container names (prefixed with `/`).
        Service:
          type: string
          description: Compose service name (if part of a stack).
        State:
          type: string
          description: Container state.
          enum: [running, exited, paused, restarting, dead, created]
        Status:
          type: string
          description: Human-readable status string (e.g., "Up 2 hours").
        Ports:
          type: array
          items:
            type: object
            properties:
              PrivatePort:
                type: integer
              PublicPort:
                type: integer

    Node:
      type: object
      required: [id, name, type, compose_dir, is_default, created_at]
      properties:
        id:
          type: integer
        name:
          type: string
        type:
          type: string
          enum: [local, remote]
        compose_dir:
          type: string
          description: Base directory for Docker Compose stacks on this node.
        is_default:
          type: integer
          enum: [0, 1]
          description: Whether this is the default node (1 = true).
        api_url:
          type: string
          description: Remote node API URL (only for remote nodes).
        api_token:
          type: string
          description: Redacted API token for remote node authentication.
        created_at:
          type: integer
          description: Unix timestamp (seconds).

    Webhook:
      type: object
      required: [id, name, stack_name, action, secret, enabled, created_at]
      properties:
        id:
          type: integer
        name:
          type: string
        stack_name:
          type: string
        action:
          type: string
          enum: [deploy, restart, stop, start, pull]
        secret:
          type: string
          description: Masked secret (e.g., `****...****`). Full secret is only returned on creation.
        enabled:
          type: integer
          enum: [0, 1]
        created_at:
          type: integer
          description: Unix timestamp (seconds).

    WebhookExecution:
      type: object
      properties:
        id:
          type: integer
        webhook_id:
          type: integer
        triggered_at:
          type: integer
          description: Unix timestamp (seconds).
        status:
          type: string
        response:
          type: string
        error:
          type: ["string", "null"]

    ApiToken:
      type: object
      required: [id, name, scope, user_id, created_at]
      properties:
        id:
          type: integer
        name:
          type: string
        scope:
          type: string
          enum: [read-only, deploy-only, full-admin]
        user_id:
          type: integer
        created_at:
          type: integer
          description: Unix timestamp (seconds).
        expires_at:
          type: ["integer", "null"]
          description: Unix timestamp (seconds) or null for no expiry.
        revoked_at:
          type: ["integer", "null"]
        last_used_at:
          type: ["integer", "null"]

    FleetNodeOverview:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        type:
          type: string
          enum: [local, remote]
        status:
          type: string
          enum: [online, offline]
        stats:
          type: ["object", "null"]
          description: Container statistics for the node.
        systemStats:
          type: ["object", "null"]
          description: Host system resource statistics.
        stacks:
          type: ["integer", "null"]
          description: Number of stacks on this node.

    FleetSnapshot:
      type: object
      required: [id, description, created_by, created_at, node_count, stack_count]
      properties:
        id:
          type: integer
        description:
          type: string
        created_by:
          type: string
        created_at:
          type: integer
          description: Unix timestamp (seconds).
        node_count:
          type: integer
        stack_count:
          type: integer
        skipped_nodes:
          type: string
          description: JSON-stringified array of skipped node names.

    FleetSnapshotDetail:
      allOf:
        - $ref: "#/components/schemas/FleetSnapshot"
        - type: object
          properties:
            nodes:
              type: array
              items:
                type: object
                properties:
                  nodeId:
                    type: integer
                  nodeName:
                    type: string
                  stacks:
                    type: array
                    items:
                      type: object
                      properties:
                        stackName:
                          type: string
                        files:
                          type: array
                          items:
                            type: object
                            properties:
                              filename:
                                type: string
                              content:
                                type: string

    ScheduledTask:
      type: object
      required: [id, name, target_type, action, cron_expression, enabled, created_by, created_at, updated_at]
      properties:
        id:
          type: integer
        name:
          type: string
        target_type:
          type: string
          enum: [stack, fleet, system]
        target_id:
          type: ["string", "null"]
          description: Stack name (when target_type is `stack`).
        node_id:
          type: ["integer", "null"]
          description: Target node ID (when target_type is `stack`).
        action:
          type: string
          enum: [restart, snapshot, prune]
        cron_expression:
          type: string
          description: Standard cron expression (5 fields).
          example: "0 3 * * *"
        enabled:
          type: integer
          enum: [0, 1]
        created_by:
          type: string
        created_at:
          type: integer
          description: Unix timestamp (seconds).
        updated_at:
          type: integer
          description: Unix timestamp (seconds).
        last_run_at:
          type: ["integer", "null"]
        next_run_at:
          type: ["integer", "null"]
        last_status:
          type: ["string", "null"]
        last_error:
          type: ["string", "null"]
        prune_targets:
          type: ["string", "null"]
          description: JSON-stringified array of prune targets (containers, images, networks, volumes).
        target_services:
          type: ["string", "null"]
          description: JSON-stringified array of service names to restart.
        prune_label_filter:
          type: ["string", "null"]
          description: Docker label filter for prune operations.

    ScheduledTaskRun:
      type: object
      required: [id, task_id, started_at, status, triggered_by]
      properties:
        id:
          type: integer
        task_id:
          type: integer
        started_at:
          type: integer
          description: Unix timestamp (seconds).
        completed_at:
          type: ["integer", "null"]
        triggered_by:
          type: string
          enum: [scheduled, manual]
        status:
          type: string
          enum: [pending, running, success, failed]
        output:
          type: ["string", "null"]
        error:
          type: ["string", "null"]

    Registry:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
          example: GitHub Container Registry
        url:
          type: string
          example: ghcr.io
        type:
          type: string
          enum: [dockerhub, ghcr, ecr, custom]
        username:
          type: string
        aws_region:
          type: ["string", "null"]
          description: AWS region (only present for ECR registries).
        created_at:
          type: integer
          description: Unix timestamp (seconds).

    RegistryCreate:
      type: object
      required: [name, url, type, username, secret]
      properties:
        name:
          type: string
          maxLength: 100
          example: GitHub Container Registry
        url:
          type: string
          maxLength: 500
          example: ghcr.io
        type:
          type: string
          enum: [dockerhub, ghcr, ecr, custom]
        username:
          type: string
          example: my-user
        secret:
          type: string
          description: Password, token, or access key. Write-only — never returned in GET responses.
        aws_region:
          type: string
          description: Required when `type` is `ecr`.
          example: us-east-1

    ImageUpdateStatus:
      type: object
      properties:
        stack_name:
          type: string
          description: Name of the stack.
        has_updates:
          type: integer
          enum: [0, 1]
          description: Whether any images in this stack have newer versions available.
        last_checked:
          type: ["integer", "null"]
          description: Unix timestamp of the last check.

  responses:
    Unauthorized:
      description: Authentication required. Provide a valid Bearer token.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "Authentication required"
    Forbidden:
      description: Insufficient permissions or license tier.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "This feature requires a Skipper or Admiral license."
            code: "PAID_REQUIRED"
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    InternalError:
      description: Internal server error.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

paths:
  # ── Health ──────────────────────────────────────────────
  /api/health:
    get:
      operationId: getHealth
      tags: [Health]
      summary: Health check
      description: Returns instance health status and uptime. No authentication required. Useful for uptime monitors and Docker HEALTHCHECK.
      security: []
      responses:
        "200":
          description: Instance is healthy.
          content:
            application/json:
              schema:
                type: object
                required: [status, uptime]
                properties:
                  status:
                    type: string
                    example: ok
                  uptime:
                    type: number
                    description: Process uptime in seconds.

  /api/meta:
    get:
      operationId: getMeta
      tags: [Health]
      summary: Instance metadata
      description: Returns this Sencho instance's version and list of supported capabilities. No authentication required. Used by nodes to negotiate feature compatibility.
      security: []
      responses:
        "200":
          description: Instance metadata.
          content:
            application/json:
              schema:
                type: object
                required: [version, capabilities]
                properties:
                  version:
                    type: string
                    description: Sencho version (semver).
                    example: "0.32.0"
                  capabilities:
                    type: array
                    description: List of feature capabilities this instance supports.
                    items:
                      type: string
                    example: ["stacks", "containers", "fleet", "auto-updates", "host-console"]

  /api/system/update:
    post:
      operationId: triggerSelfUpdate
      tags: [Health]
      summary: Trigger self-update
      description: |
        Instructs this Sencho instance to pull the latest Docker image and recreate its own container.
        Returns 202 immediately; the actual update happens asynchronously after the response is sent.
        Requires the instance to be deployed via Docker Compose with the Docker socket mounted.
      responses:
        "202":
          description: Update initiated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: "Update initiated. The server will restart shortly."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Self-update not available (not running in Docker Compose).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # ── Stacks ──────────────────────────────────────────────
  /api/stacks:
    get:
      operationId: listStacks
      tags: [Stacks]
      summary: List all stacks
      description: Returns an array of stack directory names on the target node.
      parameters:
        - $ref: "#/components/parameters/nodeId"
        - $ref: "#/components/parameters/nodeIdQuery"
      responses:
        "200":
          description: Array of stack names.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
                example: ["traefik", "portainer", "monitoring"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      operationId: createStack
      tags: [Stacks]
      summary: Create a new stack
      description: Creates a new stack directory with a default `compose.yaml` file.
      parameters:
        - $ref: "#/components/parameters/nodeId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stackName]
              properties:
                stackName:
                  type: string
                  description: Stack name. Alphanumeric characters, hyphens, and underscores only.
                  pattern: "^[a-zA-Z0-9_-]+$"
                  example: my-new-stack
      responses:
        "201":
          description: Stack created.
          content:
            application/json:
              schema:
                type: object
                required: [message, name]
                properties:
                  message:
                    type: string
                    example: Stack created successfully
                  name:
                    type: string
                    example: my-new-stack
        "400":
          description: Invalid stack name.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Stack already exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Stack already exists
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}:
    get:
      operationId: getStack
      tags: [Stacks]
      summary: Get stack compose file
      description: Returns the raw `compose.yaml` content for the specified stack.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Raw compose.yaml content.
          content:
            text/plain:
              schema:
                type: string
        "400":
          description: Invalid stack name.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      operationId: updateStack
      tags: [Stacks]
      summary: Update stack compose file
      description: Overwrites the `compose.yaml` content for the specified stack. Requires `stack:edit` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  description: Full compose.yaml content.
      responses:
        "200":
          description: Stack saved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessMessage"
              example:
                message: Stack saved successfully
        "400":
          description: Invalid stack name or content.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      operationId: deleteStack
      tags: [Stacks]
      summary: Delete stack
      description: Permanently deletes the stack directory and all its files. Requires `stack:delete` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Stack deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessMessage"
              example:
                message: Stack deleted successfully
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/envs:
    get:
      operationId: getStackEnvFiles
      tags: [Stacks]
      summary: List env file paths
      description: Returns the list of environment file paths referenced in the stack's compose file (`env_file` directives).
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: List of env file paths.
          content:
            application/json:
              schema:
                type: object
                required: [envFiles]
                properties:
                  envFiles:
                    type: array
                    items:
                      type: string
                    example: [".env", "config/database.env"]
        "400":
          description: Invalid stack name.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/env:
    get:
      operationId: getStackEnv
      tags: [Stacks]
      summary: Get env file content
      description: Returns the content of an environment file. Defaults to `.env` if no `file` query parameter is specified.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
        - name: file
          in: query
          required: false
          description: Path to a specific env file (must be in the resolved env files list).
          schema:
            type: string
      responses:
        "200":
          description: Raw env file content (KEY=VALUE format).
          content:
            text/plain:
              schema:
                type: string
        "400":
          description: Invalid stack name or disallowed file path.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Env file not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: Env file not found
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      operationId: updateStackEnv
      tags: [Stacks]
      summary: Update env file
      description: Overwrites an environment file's content. Requires `stack:edit` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
        - name: file
          in: query
          required: false
          description: Path to a specific env file to update.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  description: Full env file content (KEY=VALUE format).
      responses:
        "200":
          description: Env file saved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessMessage"
              example:
                message: Env file saved successfully
        "400":
          description: Invalid input.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/containers:
    get:
      operationId: getStackContainers
      tags: [Stacks]
      summary: List stack containers
      description: Returns all containers belonging to the specified stack.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Array of container objects.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Container"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/services:
    get:
      operationId: getStackServices
      tags: [Stacks]
      summary: List stack services
      description: Returns the service names defined in the stack's compose file.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Array of service names.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
                example: ["web", "db", "redis"]
        "400":
          description: Invalid stack name.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/deploy:
    post:
      operationId: deployStack
      tags: [Stacks]
      summary: Deploy stack
      description: |
        Runs `docker compose up -d` for the stack. On Skipper/Admiral tier, uses atomic deployment
        with automatic rollback on failure. Requires `stack:deploy` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Stack deployed successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessMessage"
              example:
                message: Deployed successfully
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          description: Deployment failed.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                  rolledBack:
                    type: boolean
                    description: Whether the stack was automatically rolled back (Skipper/Admiral tier).

  /api/stacks/{stackName}/down:
    post:
      operationId: downStack
      tags: [Stacks]
      summary: Tear down stack
      description: Runs `docker compose down` for the stack, removing containers and networks. Requires `stack:deploy` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Command started.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    example: Command started
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/start:
    post:
      operationId: startStack
      tags: [Stacks]
      summary: Start stack containers
      description: Starts all stopped containers in the stack via the Docker Engine API. Requires `stack:deploy` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Containers started.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No containers found for this stack.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/stop:
    post:
      operationId: stopStack
      tags: [Stacks]
      summary: Stop stack containers
      description: Stops all running containers in the stack via the Docker Engine API. Requires `stack:deploy` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Containers stopped.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No containers found for this stack.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/restart:
    post:
      operationId: restartStack
      tags: [Stacks]
      summary: Restart stack containers
      description: Restarts all containers in the stack via the Docker Engine API. Requires `stack:deploy` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Containers restarted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No containers found for this stack.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/update:
    post:
      operationId: updateStackImages
      tags: [Stacks]
      summary: Pull and recreate stack
      description: |
        Pulls latest images and recreates containers (`docker compose pull && up -d`).
        On Skipper/Admiral tier, uses atomic update with automatic rollback on failure.
        Requires `stack:deploy` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Update completed.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    example: Update completed
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          description: Update failed.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                  rolledBack:
                    type: boolean

  /api/stacks/{stackName}/rollback:
    post:
      operationId: rollbackStack
      tags: [Stacks]
      summary: Rollback stack
      description: Restores the stack to its previous deployment state. Requires Skipper or Admiral license and `stack:deploy` permission.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Stack rolled back.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessMessage"
              example:
                message: Stack rolled back successfully.
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No backup available.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: No backup available for this stack.
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stacks/{stackName}/backup:
    get:
      operationId: getStackBackupStatus
      tags: [Stacks]
      summary: Check backup status
      description: Returns whether a deployment backup exists for this stack and when it was created.
      parameters:
        - $ref: "#/components/parameters/stackName"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Backup status.
          content:
            application/json:
              schema:
                type: object
                required: [exists]
                properties:
                  exists:
                    type: boolean
                  timestamp:
                    type: ["integer", "null"]
                    description: Unix timestamp of the backup, or null if no backup exists.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"


  # ── Containers ──────────────────────────────────────────
  /api/containers:
    get:
      operationId: listContainers
      tags: [Containers]
      summary: List all containers
      description: Returns all running containers on the target node.
      parameters:
        - $ref: "#/components/parameters/nodeId"
        - $ref: "#/components/parameters/nodeIdQuery"
      responses:
        "200":
          description: Array of container objects.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Container"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/containers/{id}/logs:
    get:
      operationId: streamContainerLogs
      tags: [Containers]
      summary: Stream container logs
      description: |
        Streams container logs as Server-Sent Events (SSE). The connection remains
        open and pushes new log lines as `data:` events in real time.
      parameters:
        - $ref: "#/components/parameters/idPath"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: SSE log stream.
          content:
            text/event-stream:
              schema:
                type: string
                description: Each event is a `data:` line containing a JSON-encoded log string.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/containers/{id}/start:
    post:
      operationId: startContainer
      tags: [Containers]
      summary: Start container
      description: Starts a stopped container. Requires admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Container started.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessMessage"
              example:
                message: Container started
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/containers/{id}/stop:
    post:
      operationId: stopContainer
      tags: [Containers]
      summary: Stop container
      description: Stops a running container. Requires admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Container stopped.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessMessage"
              example:
                message: Container stopped
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/containers/{id}/restart:
    post:
      operationId: restartContainer
      tags: [Containers]
      summary: Restart container
      description: Restarts a container. Requires admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
        - $ref: "#/components/parameters/nodeId"
      responses:
        "200":
          description: Container restarted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessMessage"
              example:
                message: Container restarted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── API Tokens ──────────────────────────────────────────
  /api/api-tokens:
    post:
      operationId: createApiToken
      tags: [API Tokens]
      summary: Create API token
      description: |
        Generates a new scoped API token. The full token is only returned in the creation response
        and cannot be retrieved again. Requires Admiral license and admin role.

        **Note:** API tokens cannot create other API tokens.
      responses:
        "201":
          description: Token created. The `token` field contains the full JWT. Save it now; it will not be shown again.
          content:
            application/json:
              schema:
                type: object
                required: [id, token]
                properties:
                  id:
                    type: integer
                  token:
                    type: string
                    description: Full JWT token. Store securely — this is the only time it's returned.
        "400":
          description: Validation error (missing/invalid fields, or maximum of 25 active tokens reached).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: An active token with this name already exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scope]
              properties:
                name:
                  type: string
                  description: Human-readable token name. Must be unique among the user's active tokens.
                  maxLength: 100
                  example: CI/CD Deploy Token
                scope:
                  type: string
                  description: |
                    Permission scope:
                    - `read-only` — GET requests only
                    - `deploy-only` — Read + stack deploy/stop/restart operations
                    - `full-admin` — All operations (except license, user, and token management)
                  enum: [read-only, deploy-only, full-admin]
                expires_in:
                  type: ["integer", "null"]
                  description: Token lifetime in days. Use `null` for no expiry.
                  enum: [30, 60, 90, 365, null]
                  example: 90
    get:
      operationId: listApiTokens
      tags: [API Tokens]
      summary: List API tokens
      description: |
        Returns all API tokens for the current user. Token hashes are never exposed.
        Requires Admiral license and admin role.

        **Note:** API tokens cannot list other API tokens.
      responses:
        "200":
          description: Array of token metadata.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ApiToken"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/api-tokens/{id}:
    delete:
      operationId: revokeApiToken
      tags: [API Tokens]
      summary: Revoke API token
      description: |
        Permanently revokes an API token. Users can only revoke their own tokens.
        Requires Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Token revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "400":
          description: Invalid token ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Webhooks ────────────────────────────────────────────
  /api/webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhooks
      description: Returns all configured webhooks with masked secrets. Requires Skipper or Admiral license.
      responses:
        "200":
          description: Array of webhook objects.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Webhook"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Create webhook
      description: |
        Creates a new webhook for a stack. The webhook secret is auto-generated and only
        returned in the creation response. Requires Skipper or Admiral license and admin role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, stack_name, action]
              properties:
                name:
                  type: string
                  example: GitHub Deploy Hook
                stack_name:
                  type: string
                  description: Target stack name.
                  example: my-app
                action:
                  type: string
                  enum: [deploy, restart, stop, start, pull]
                  description: Action to execute when the webhook is triggered.
                enabled:
                  type: boolean
                  default: true
      responses:
        "201":
          description: Webhook created. The `secret` field is the HMAC signing key — save it now.
          content:
            application/json:
              schema:
                type: object
                required: [id, secret]
                properties:
                  id:
                    type: integer
                  secret:
                    type: string
                    description: HMAC-SHA256 signing secret. Store securely — this is the only time it's returned in full.
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/webhooks/{id}:
    put:
      operationId: updateWebhook
      tags: [Webhooks]
      summary: Update webhook
      description: Updates webhook configuration. Requires Skipper or Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                stack_name:
                  type: string
                action:
                  type: string
                  enum: [deploy, restart, stop, start, pull]
                enabled:
                  type: boolean
      responses:
        "200":
          description: Webhook updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete webhook
      description: Permanently deletes a webhook. Requires Skipper or Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Webhook deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/webhooks/{id}/history:
    get:
      operationId: getWebhookHistory
      tags: [Webhooks]
      summary: Get webhook execution history
      description: Returns the execution log for a webhook. Requires Skipper or Admiral license.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Array of execution records.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/WebhookExecution"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/webhooks/{id}/trigger:
    post:
      operationId: triggerWebhook
      tags: [Webhooks]
      summary: Trigger webhook
      description: |
        Externally triggers a webhook action. This endpoint is public but requires a valid
        HMAC-SHA256 signature in the `X-Webhook-Signature` header.

        Compute the signature as: `sha256=` + HMAC-SHA256(raw_request_body, webhook_secret).
      security:
        - webhookSignature: []
      parameters:
        - $ref: "#/components/parameters/idPath"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                action:
                  type: string
                  description: Override the default webhook action.
      responses:
        "202":
          description: Webhook accepted and action queued.
          content:
            application/json:
              schema:
                type: object
                required: [message, action]
                properties:
                  message:
                    type: string
                    example: Webhook accepted
                  action:
                    type: string
                    example: deploy
        "401":
          description: Missing or invalid signature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Webhook not found or disabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Nodes ───────────────────────────────────────────────
  /api/nodes:
    get:
      operationId: listNodes
      tags: [Nodes]
      summary: List all nodes
      description: Returns all registered nodes (local and remote).
      responses:
        "200":
          description: Array of node objects.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Node"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      operationId: createNode
      tags: [Nodes]
      summary: Register a new node
      description: |
        Adds a new local or remote node. Remote nodes require an API URL pointing to
        another Sencho instance and an API token for authentication.
        Requires `node:manage` permission.

        **Note:** API tokens cannot manage nodes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, type]
              properties:
                name:
                  type: string
                  example: production-server
                type:
                  type: string
                  enum: [local, remote]
                compose_dir:
                  type: string
                  description: Base directory for Compose stacks (defaults to `/app/compose`).
                is_default:
                  type: boolean
                  description: Set as the default node.
                api_url:
                  type: string
                  description: Remote Sencho instance URL (required for remote nodes).
                  example: https://sencho.example.com:1852
                api_token:
                  type: string
                  description: API token for authenticating with the remote Sencho instance.
      responses:
        "200":
          description: Node created.
          content:
            application/json:
              schema:
                type: object
                required: [success, id]
                properties:
                  success:
                    type: boolean
                  id:
                    type: integer
                    description: New node ID.
                  warning:
                    type: string
                    description: Warning if the remote URL uses plain HTTP.
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Node name already exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/nodes/{id}:
    get:
      operationId: getNode
      tags: [Nodes]
      summary: Get node details
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Node object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Node"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      operationId: updateNode
      tags: [Nodes]
      summary: Update node
      description: Updates node configuration. Requires `node:manage` permission.
      parameters:
        - $ref: "#/components/parameters/idPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                compose_dir:
                  type: string
                is_default:
                  type: boolean
                api_url:
                  type: string
                api_token:
                  type: string
      responses:
        "200":
          description: Node updated.
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                  warning:
                    type: string
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      operationId: deleteNode
      tags: [Nodes]
      summary: Delete node
      description: Removes a node from the fleet. Requires `node:manage` permission.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Node deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/nodes/{id}/test:
    post:
      operationId: testNodeConnection
      tags: [Nodes]
      summary: Test node connection
      description: Tests connectivity to a node. For remote nodes, pings the remote Sencho instance's health endpoint.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Connection test result.
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  error:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/nodes/{id}/meta:
    get:
      operationId: getNodeMeta
      tags: [Nodes]
      summary: Get node capabilities
      description: "Returns the version and supported capabilities for a specific node. For local nodes, returns this instance's own metadata. For remote nodes, relays the response from the remote instance's `/api/meta` endpoint. Returns `{ version: null, capabilities: [] }` if the remote node is offline or doesn't support the meta endpoint."
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Node capability metadata.
          content:
            application/json:
              schema:
                type: object
                required: [version, capabilities]
                properties:
                  version:
                    type: string
                    nullable: true
                    description: Sencho version of the node, or null if unavailable.
                    example: "0.31.0"
                  capabilities:
                    type: array
                    description: List of feature capabilities the node supports. Empty array if unavailable.
                    items:
                      type: string
                    example: ["stacks", "containers", "fleet"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ── Fleet ───────────────────────────────────────────────
  /api/fleet/overview:
    get:
      operationId: getFleetOverview
      tags: [Fleet]
      summary: Fleet overview
      description: Returns an aggregated overview of all nodes including status, container stats, and stack counts.
      responses:
        "200":
          description: Array of fleet node overviews.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/FleetNodeOverview"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/fleet/node/{nodeId}/stacks:
    get:
      operationId: getFleetNodeStacks
      tags: [Fleet]
      summary: List stacks on a fleet node
      description: Returns stack names from a specific fleet node.
      parameters:
        - name: nodeId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Array of stack names.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
        "502":
          description: Failed to reach remote node.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Remote node not configured.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/fleet/node/{nodeId}/stacks/{stackName}/containers:
    get:
      operationId: getFleetNodeStackContainers
      tags: [Fleet]
      summary: List containers in a fleet node stack
      description: Returns containers for a specific stack on a specific fleet node.
      parameters:
        - name: nodeId
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/stackName"
      responses:
        "200":
          description: Array of container objects.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Container"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          description: Remote node not configured.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/fleet/update-status:
    get:
      operationId: getFleetUpdateStatus
      tags: [Fleet]
      summary: Fleet update status
      description: Returns version comparison and active update status for all nodes. Compares each node's version against the gateway's version.
      responses:
        "200":
          description: Update status for all nodes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  nodes:
                    type: array
                    items:
                      type: object
                      properties:
                        nodeId:
                          type: integer
                        name:
                          type: string
                        type:
                          type: string
                          enum: [local, remote]
                        version:
                          type: string
                          nullable: true
                        latestVersion:
                          type: string
                        updateAvailable:
                          type: boolean
                        updateStatus:
                          type: string
                          nullable: true
                          enum: [updating, completed, timeout, failed, null]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/fleet/nodes/{nodeId}/update:
    post:
      operationId: triggerNodeUpdate
      tags: [Fleet]
      summary: Trigger node update
      description: Initiates a self-update on the specified node. For remote nodes, sends the update command via the proxy. For local nodes, triggers the update directly.
      parameters:
        - name: nodeId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Update initiated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Node not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Update already in progress.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Self-update not available on the target node.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/fleet/update-all:
    post:
      operationId: triggerFleetUpdateAll
      tags: [Fleet]
      summary: Update all outdated nodes
      description: Triggers self-update on all remote nodes running an older version than the gateway. Local nodes are excluded from bulk updates.
      responses:
        "202":
          description: Bulk update initiated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updating:
                    type: array
                    items:
                      type: string
                    description: Names of nodes where update was triggered.
                  skipped:
                    type: array
                    items:
                      type: string
                    description: Names of nodes that were skipped.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/fleet/snapshots:
    post:
      operationId: createFleetSnapshot
      tags: [Fleet]
      summary: Create fleet snapshot
      description: Creates a point-in-time backup of all compose files across all nodes. Requires Skipper or Admiral license and admin role.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                description:
                  type: string
                  description: Optional description for the snapshot.
                  example: Pre-migration backup
      responses:
        "201":
          description: Snapshot created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FleetSnapshot"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"
    get:
      operationId: listFleetSnapshots
      tags: [Fleet]
      summary: List fleet snapshots
      description: Returns paginated fleet snapshots. Requires Skipper or Admiral license.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: Paginated snapshot list.
          content:
            application/json:
              schema:
                type: object
                required: [snapshots, total]
                properties:
                  snapshots:
                    type: array
                    items:
                      $ref: "#/components/schemas/FleetSnapshot"
                  total:
                    type: integer
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/fleet/snapshots/{id}:
    get:
      operationId: getFleetSnapshot
      tags: [Fleet]
      summary: Get snapshot details
      description: Returns full snapshot details including all captured files grouped by node and stack. Requires Skipper or Admiral license.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Snapshot with file contents.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FleetSnapshotDetail"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      operationId: deleteFleetSnapshot
      tags: [Fleet]
      summary: Delete snapshot
      description: Permanently deletes a fleet snapshot. Requires Skipper or Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Snapshot deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/fleet/snapshots/{id}/restore:
    post:
      operationId: restoreFleetSnapshot
      tags: [Fleet]
      summary: Restore from snapshot
      description: Restores a specific stack on a specific node from the snapshot. Optionally redeploys after restore. Requires Skipper or Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nodeId, stackName]
              properties:
                nodeId:
                  type: integer
                  description: Target node ID to restore to.
                stackName:
                  type: string
                  description: Stack to restore.
                redeploy:
                  type: boolean
                  default: false
                  description: Whether to redeploy the stack after restoring files.
      responses:
        "200":
          description: Stack restored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessMessage"
              example:
                message: Stack restored successfully.
        "400":
          description: Missing required fields.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Scheduled Tasks ─────────────────────────────────────
  /api/scheduled-tasks:
    get:
      operationId: listScheduledTasks
      tags: [Scheduled Tasks]
      summary: List scheduled tasks
      description: Returns all scheduled tasks. Requires Admiral license and admin role.
      responses:
        "200":
          description: Array of scheduled task objects.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ScheduledTask"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      operationId: createScheduledTask
      tags: [Scheduled Tasks]
      summary: Create scheduled task
      description: |
        Creates a new recurring task. Action-target rules:
        - `restart` requires `target_type: stack` (with `target_id` and `node_id`)
        - `snapshot` requires `target_type: fleet`
        - `prune` requires `target_type: system`

        Requires Admiral license and admin role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, target_type, action, cron_expression]
              properties:
                name:
                  type: string
                  example: Nightly restart
                target_type:
                  type: string
                  enum: [stack, fleet, system]
                target_id:
                  type: string
                  description: Stack name (required when target_type is `stack`).
                node_id:
                  type: integer
                  description: Target node ID (required when target_type is `stack`).
                action:
                  type: string
                  enum: [restart, snapshot, prune]
                cron_expression:
                  type: string
                  description: Standard 5-field cron expression.
                  example: "0 3 * * *"
                enabled:
                  type: boolean
                  default: true
                prune_targets:
                  type: array
                  items:
                    type: string
                    enum: [containers, images, networks, volumes]
                  description: Resources to prune (only for `prune` action).
                target_services:
                  type: array
                  items:
                    type: string
                  description: Specific services to restart (only for `restart` action).
                prune_label_filter:
                  type: string
                  description: Docker label filter for prune operations.
      responses:
        "201":
          description: Task created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScheduledTask"
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/scheduled-tasks/{id}:
    get:
      operationId: getScheduledTask
      tags: [Scheduled Tasks]
      summary: Get scheduled task
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Scheduled task object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScheduledTask"
        "400":
          description: Invalid task ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    put:
      operationId: updateScheduledTask
      tags: [Scheduled Tasks]
      summary: Update scheduled task
      description: Updates task configuration. Same validation rules as creation apply. Requires Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                target_type:
                  type: string
                  enum: [stack, fleet, system]
                target_id:
                  type: string
                node_id:
                  type: integer
                action:
                  type: string
                  enum: [restart, snapshot, prune]
                cron_expression:
                  type: string
                enabled:
                  type: boolean
                prune_targets:
                  type: array
                  items:
                    type: string
                target_services:
                  type: array
                  items:
                    type: string
                prune_label_filter:
                  type: string
      responses:
        "200":
          description: Task updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScheduledTask"
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      operationId: deleteScheduledTask
      tags: [Scheduled Tasks]
      summary: Delete scheduled task
      description: Permanently deletes a scheduled task. Requires Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Task deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "400":
          description: Invalid task ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/scheduled-tasks/{id}/toggle:
    patch:
      operationId: toggleScheduledTask
      tags: [Scheduled Tasks]
      summary: Toggle task enabled/disabled
      description: Flips the enabled state of a scheduled task. Requires Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Task toggled. Returns updated task.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScheduledTask"
        "400":
          description: Invalid task ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/scheduled-tasks/{id}/run:
    post:
      operationId: runScheduledTask
      tags: [Scheduled Tasks]
      summary: Run task immediately
      description: Executes the scheduled task immediately, regardless of its cron schedule. Requires Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Task executed. Returns updated task with `last_run_at`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScheduledTask"
        "400":
          description: Invalid task ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/scheduled-tasks/{id}/runs:
    get:
      operationId: listScheduledTaskRuns
      tags: [Scheduled Tasks]
      summary: List task execution history
      description: Returns paginated execution history for a scheduled task. Requires Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: Array of task run records.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ScheduledTaskRun"
        "400":
          description: Invalid task ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/scheduled-tasks/{id}/runs/export:
    get:
      operationId: exportScheduledTaskRuns
      tags: [Scheduled Tasks]
      summary: Export task history as CSV
      description: Downloads the execution history for a scheduled task as a CSV file. Requires Admiral license and admin role.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: CSV file download.
          content:
            text/csv:
              schema:
                type: string
                description: "CSV with columns: Timestamp, Source, Status, Duration (s), Details"
        "400":
          description: Invalid task ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Registries ──────────────────────────────────────────
  /api/registries:
    get:
      operationId: listRegistries
      tags: [Registries]
      summary: List registries
      description: |
        Returns all configured private container registries. Secrets are never included in the response.
        Requires Admiral license and admin role. API tokens receive `SCOPE_DENIED`.
      responses:
        "200":
          description: Array of registry objects.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Registry"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      operationId: createRegistry
      tags: [Registries]
      summary: Create registry
      description: |
        Adds a new private container registry. The secret is encrypted at rest using AES-256-GCM.
        Requires Admiral license and admin role. API tokens receive `SCOPE_DENIED`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegistryCreate"
      responses:
        "201":
          description: Registry created.
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id:
                    type: integer
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/registries/{id}:
    put:
      operationId: updateRegistry
      tags: [Registries]
      summary: Update registry
      description: |
        Updates an existing registry. All fields are optional — only provided fields are changed.
        Requires Admiral license and admin role. API tokens receive `SCOPE_DENIED`.
      parameters:
        - $ref: "#/components/parameters/idPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegistryCreate"
      responses:
        "200":
          description: Registry updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      operationId: deleteRegistry
      tags: [Registries]
      summary: Delete registry
      description: |
        Removes a registry and its encrypted credentials.
        Requires Admiral license and admin role. API tokens receive `SCOPE_DENIED`.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Registry deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessBoolean"
        "400":
          description: Invalid registry ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/registries/{id}/test:
    post:
      operationId: testRegistry
      tags: [Registries]
      summary: Test registry connection
      description: |
        Tests connectivity and authentication against a configured registry.
        Requires Admiral license and admin role. API tokens receive `SCOPE_DENIED`.
      parameters:
        - $ref: "#/components/parameters/idPath"
      responses:
        "200":
          description: Test result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
        "400":
          description: Invalid registry ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Image Updates ───────────────────────────────────────
  /api/image-updates:
    get:
      operationId: getImageUpdates
      tags: [Image Updates]
      summary: Get image update status
      description: Returns the update availability status for all stacks.
      responses:
        "200":
          description: Array of update status objects per stack.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ImageUpdateStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/image-updates/refresh:
    post:
      operationId: refreshImageUpdates
      tags: [Image Updates]
      summary: Trigger image update check
      description: |
        Triggers a background check for newer image versions across all stacks.
        Rate limited to one refresh per 10 minutes. Requires admin role.
      responses:
        "200":
          description: Refresh started.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Image update check started in background.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          description: Rate limited — wait at least 10 minutes between refreshes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/image-updates/status:
    get:
      operationId: getImageUpdateCheckStatus
      tags: [Image Updates]
      summary: Check if update scan is running
      description: Returns whether an image update check is currently in progress.
      responses:
        "200":
          description: Check status.
          content:
            application/json:
              schema:
                type: object
                required: [checking]
                properties:
                  checking:
                    type: boolean
                    description: "`true` if a scan is currently running."
        "401":
          $ref: "#/components/responses/Unauthorized"
