runin.eu

API reference

The same tenant-scoped operations behind the CLI and SSH command server.

Authenticate

The public API is https://api.runin.eu. Send your account's access token in the Authorization: Bearer header over HTTPS. Keep tokens in protected local configuration or environment files. Do not put them in URLs.

Machine lists and usage are scoped to your account. Requests for another account's Machine do not grant access. Administrative operations require operator credentials.

Use the OpenAPI document

Download the OpenAPI specification for request fields, responses, errors and authentication. This file is copied from the server's checked-in specification during the website build.

Lifecycle requests are asynchronous. Acceptance is not completion; poll the returned Machine, Template or Snapshot state. The customer CLI and SSH commands provide these waits.

Read the full specification
# Forgesmith API v1 (ticket 033). This file is the source of truth for the
# Control Plane's public surface: docs/m3-contract.md points at it, the CLI
# speaks it, and internal/controlplane/api serves it.
#
# The handlers are written by hand rather than generated. The surface is small
# and two of its shapes -- the NDJSON exec stream and the size field that is
# either a preset name or an object -- are ones a generator renders worse than
# a person does. What keeps the two in step instead of a generator is
# TestSpecMatchesRoutes in internal/controlplane/api: it fails if a route here
# has no handler, or a handler no route. Add a path here first, then a route.
#
# Indentation matters to that test, which reads this file with a small scanner
# rather than a YAML library (the Control Plane is standard library only):
# every path is a key at two spaces, every method a key at four.
openapi: 3.1.0

info:
  title: Forgesmith API
  version: "1.0.0"
  summary: Machines, Templates, Snapshots and exec on EU bare metal.
  description: |
    The Control Plane's public API. A Machine is a Firecracker microVM owned by
    one Tenant; a Template is the versioned disk image it starts from; a
    Snapshot is the captured state of a paused Machine, and a Fork is a new
    Machine made from one.

    Every request carries `Authorization: Bearer <api key>`. A key belongs to
    one Tenant, and everything a request can see or change belongs to that
    Tenant: a Machine of another Tenant is not found rather than forbidden.

    Machine lifecycle calls are asynchronous. Create answers 201 as soon as the
    Machine's workflow has started, and pause, resume, fork and delete answer
    202; poll `GET /v1/machines/{id}` for the phase.

    Errors are RFC 7807 problem documents with the media type
    `application/problem+json`.

servers:
  - url: https://api.runin.eu
    description: The public API (Caddy on runin-control; /v1 only)
  - url: http://localhost:8080
    description: fsm-control serve on a development machine

security:
  - bearerAuth: []

tags:
  - name: tenants
  - name: members
  - name: machines
  - name: snapshots
  - name: templates
  - name: keys
  - name: hosts
  - name: meta

paths:
  /v1/admin/abuse-reports:
    post:
      operationId: recordAbuseNotice
      summary: Operator-only intake of an abuse notice for review
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [machine, reference, summary]
              properties:
                machine: {type: string}
                reference: {type: string, maxLength: 256}
                summary: {type: string, maxLength: 4096}
      responses:
        '201':
          description: Durable notice recorded for review; does not automatically pause
        '403':
          description: Admin credential required
  /v1/health:
    get:
      tags: [meta]
      operationId: health
      summary: Liveness of the API
      description: The one route that needs no API key.
      security: []
      responses:
        "200":
          description: The API is serving.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    const: ok
                  version:
                    type: string

  /v1/usage:
    get:
      tags: [tenants]
      operationId: tenantUsage
      summary: Current allocations and Pool quota status for the caller's Tenant
      description: |
        A single database observation, including live reservations. No Tenant
        selector, even for admins. This is an allocation ledger, not utilization
        or Host free capacity. CPU, memory and Machine count quotas are enforced
        for create and Fork, and CPU and memory also for resume, including
        pending reservations. Defaults are 8 allocated vCPU, 16384 MiB and 50
        Machines; operator Pool overrides replace them. Remaining allowance
        uses the same ledger as admission. Unknown allocations prevent admission
        and remain null, never zero. Disk reports allocated guest capacity; Host
        storage categories require fresh complete observations. Positive legacy
        disk limits are not enforced.
      responses:
        "200":
          description: Current usage; Cache-Control no-store.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantUsage'
        "401":
          $ref: '#/components/responses/Unauthenticated'
        "501":
          description: This Control Plane has no allocation ledger.

  /v1/usage/history:
    get:
      tags: [tenants]
      operationId: tenantUsageHistory
      summary: Recorded allocation totals by UTC day or month
      description: |
        Caller Tenant only, including for administrators and SSH forwarding.
        The range is half-open, positive, at most 366 days and cannot end in the
        future. Defaults to the current UTC month through the server clock.
        This is prospective allocation accounting, not Host utilization or an
        invoice. Coverage is incomplete before metering started or during
        uncertain Machine state. No usage is invented for those periods.
      parameters:
        - in: query
          name: from
          schema: { type: string }
          description: Inclusive UTC date YYYY-MM-DD or RFC3339 timestamp.
        - in: query
          name: to
          schema: { type: string }
          description: Exclusive UTC date YYYY-MM-DD or RFC3339 timestamp.
        - in: query
          name: group
          schema: { type: string, enum: [day, month], default: day }
        - in: query
          name: format
          schema: { type: string, enum: [json, csv], default: json }
      responses:
        "200":
          description: Allocation history; Cache-Control no-store.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UsageHistory' }
            text/csv:
              schema: { type: string }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthenticated' }
        "501":
          description: History provider unavailable.

  /v1/whoami:
    get:
      tags: [keys]
      operationId: whoami
      summary: Who the API key of this request belongs to
      description: |
        The Tenant, the key and whether it is an admin key. `runin login`
        checks a token against this before storing it.
      responses:
        "200":
          description: The caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Whoami"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/machines:
    get:
      tags: [machines]
      operationId: listMachines
      summary: List this Tenant's Machines
      description: |
        Default listings omit retained terminal records. Use all=true for history
        including failed and destroyed Machines, or failed=true for cleanup
        discovery. The two filters are mutually exclusive and never widen Tenant
        authorization. Failed records with trusted proof of no Host runtime can
        be removed through the normal DELETE operation.

        Without scope=tenant the answer is the caller's own and shared Machines
        only; ?scope=tenant is a Tenant admin's whole-Tenant list. An operator
        key may name another Tenant with ?tenant=.
      parameters:
        - name: all
          in: query
          schema: { type: boolean, default: false }
          description: Include retained failed and destroyed records.
        - name: failed
          in: query
          schema: { type: boolean, default: false }
          description: Show failed records only.
        - name: scope
          in: query
          required: false
          description: |
            "tenant" lists every live Machine of the Tenant and needs a Tenant
            admin; without it the answer is the caller's own and shared
            Machines.
          schema:
            type: string
        - name: tenant
          in: query
          required: false
          description: Another Tenant's Machines. Admin keys only.
          schema:
            type: string
      responses:
        "200":
          description: The Machines, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MachineList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [machines]
      operationId: createMachine
      summary: Create a Machine from a Template
      description: |
        Mints a Machine ID, checks that the Tenant's Host Group has room, and
        starts the Machine's workflow. The Machine is in phase `creating` when
        this returns; the Host does the work afterwards.

        A Host Group with no room is a 409 here rather than a Machine that
        fails a few seconds later. Room is guaranteed CPU and memory: a
        `reserved` Machine needs all of its vCPUs unguaranteed on one Host, a
        `flex` one only its share, so a Host Group can be full for one class
        and not the other. The check is not the authority, though: the
        reservation inside the workflow is, so two creates that race can both
        be accepted and one of them still fail.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateMachineRequest"
      responses:
        "201":
          description: The Machine was accepted and its workflow started.
          headers:
            Location:
              description: The new Machine's URL.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Machine"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "409":
          description: |
            The Tenant's Pool is full, or no Host in its Host Group has room.
            The problem type says which.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/machines/{id}:
    get:
      tags: [machines]
      operationId: getMachine
      summary: One Machine and its current phase
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "200":
          description: The Machine.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Machine"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [machines]
      operationId: deleteMachine
      summary: Destroy a Machine
      description: Signals the Machine's workflow to destroy it and release its capacity.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "202":
          description: Destruction was signalled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Machine"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/machines/{id}/backup:
    put:
      tags: [machines]
      operationId: updateMachineBackup
      summary: Set a Machine's requested backup preference
      description: |
        Per-Machine backup opt-out (ticket 117). False asks the Host to start
        no new backup captures for this Machine; true resumes them. This is the
        requested preference, not an observation of enforcement: the Host acts
        on it at its next policy refresh, and a capture it already admitted may
        finish. Opting out deletes nothing -- existing checkpoints and their
        retention are untouched -- and re-enabling resumes the ordinary
        full/incremental workflow.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [backups_enabled]
              properties:
                backups_enabled:
                  type: boolean
      responses:
        "200":
          description: The Machine with its preference set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Machine"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/machines/{id}/abuse:
    get:
      summary: Read owned Machine abuse evidence summary and response incidents
      operationId: machineAbuse
      parameters:
        - $ref: '#/components/parameters/MachineID'
      responses:
        '200':
          description: Host evidence interval count, last observation, and incidents with response states
          content:
            application/json:
              schema:
                type: object
                required: [machine, reports, incidents]
                properties:
                  machine: {type: string}
                  reports: {type: integer}
                  last_observed_at: {type: string, format: date-time}
                  incidents:
                    type: array
                    items:
                      type: object
                      required: [id, machine, reason, state, created_at, updated_at]
                      properties:
                        id: {type: string}
                        machine: {type: string}
                        reason: {type: string}
                        state: {type: string, enum: [pending, scheduled, pause_requested, paused, failed, obsolete]}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        '404':
          description: Unknown or unowned Machine
        '503':
          description: Durable abuse reporting unavailable
  /v1/machines/{id}/pause:
    post:
      tags: [machines]
      operationId: pauseMachine
      summary: Pause a Machine, capturing a Snapshot
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "202":
          description: The pause was signalled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Machine"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/machines/{id}/resume:
    post:
      tags: [machines]
      operationId: resumeMachine
      summary: Resume a paused Machine or recover an interrupted Machine
      description: |
        Requires available Tenant CPU and memory quota and Host capacity. API
        preflight can return 409; admission repeats atomically in the workflow.
        If a concurrent allocation wins, the Machine stays paused or interrupted
        with the refusal in its status error. Interrupted recovery boots the
        surviving disk on its current Host with the same Identity after runtime
        cleanup and admission. Failed recovery preserves the disk and Snapshots.
        Recovery is explicit; a returning Host does not restart Machines itself.
        Resume of an already running Machine is idempotent.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "202":
          description: The resume was signalled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Machine"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/PoolExhausted" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/machines/{id}/fork:
    post:
      tags: [machines]
      operationId: forkMachine
      summary: Fork a Machine into one or more new Machines
      description: |
        The children's IDs are minted here, so the answer names them before any
        of them exists. Each starts in phase `creating`.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ForkRequest"
      responses:
        "202":
          description: The Fork was signalled and the children named.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MachineList"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/PoolExhausted" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/machines/{id}/commit:
    post:
      tags: [machines]
      operationId: commitMachine
      summary: Publish a Machine's disk as a new version of a Template
      description: |
        Promotion (ticket 061). The Machine's disk becomes the version that new
        Machines of that Template start from, so what it accumulated -- a warm
        Docker store, a filled cache, an installed toolchain -- is what they
        begin with.

        The Machine is paused first if it is running, and is left paused and
        unchanged. Only the disk is published: the new version is booted once
        on the Host to take the Machine's Identity, SSH host keys, machine-id
        and any CI runner registration out of it, and a Golden Snapshot of the
        result is captured at the Machine's own vCPU and memory shape.

        `version` is required and the answer is `202`: the commit takes minutes
        and the version appears in `GET /v1/templates/{name}/versions` when it
        exists. Committing the same (template, version) twice is the same
        commit, not a second one.

        A Tenant key can commit a Machine it manages into its Tenant's named
        Template. Another Tenant may use the same name independently. Platform
        names are reserved and return `409` to customer publication.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CommitRequest"
      responses:
        "202":
          description: The commit was started. The version named is the one being made.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateVersion"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/machines/{id}/exec:
    post:
      tags: [machines]
      operationId: execMachine
      summary: Run a command inside a Machine and stream its output
      description: |
        The response is a chunked `application/x-ndjson` stream, one JSON
        object per line, in this order: one `start` event carrying the guest
        pid, any number of `stdout`, `stderr` or `pty` events whose `data` is
        base64, and one final `end` event carrying the exit status.

        The stream's HTTP status is 200 as soon as the Host Agent accepted the
        command, so a failure after that arrives as an `error` event rather
        than a status code. A command that exits non-zero is not an error: its
        status is in the `end` event.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ExecRequest"
      responses:
        "200":
          description: The command ran; its output is the stream.
          content:
            application/x-ndjson:
              schema:
                $ref: "#/components/schemas/ExecEvent"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The Machine is not running.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: The Host Agent could not be reached.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  /v1/machines/{id}/snapshots:
    get:
      tags: [snapshots]
      operationId: listSnapshots
      summary: The Snapshots of one Machine
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "200":
          description: The observed Snapshots, oldest first. Unavailable workflow status returns an error, not an empty list.
          content:
            application/json:
              schema:
                type: object
                required: [snapshots]
                properties:
                  snapshots:
                    type: array
                    items:
                      $ref: "#/components/schemas/Snapshot"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [snapshots]
      operationId: createSnapshot
      summary: Snapshot a running Machine without pausing it for good
      description: |
        Ticket 111. The workflow pauses the Machine, which writes the Snapshot
        exactly as a pause does -- the Machine freezes for the seconds the
        capture takes -- and resumes it at once, re-admitting through memory
        admission like any resume. The answer carries the new Snapshot and, on
        `state: running`, `frozen_ms`: the measured pause-to-running window.

        The capture is marked with kind `manual`, which automatic retention
        never prunes. A Machine that is not running is refused with 409 before
        anything is signalled; the workflow refuses a race the same way. If
        the resume fails the Machine stays paused with its Snapshot intact,
        which `state: paused` reports, and a later resume starts it. When the
        inline wait runs out the answer is `state: requested`, and the
        Snapshot appears in the Snapshot list when it lands. Snapshots count
        against the Tenant's storage exactly as a pause's do.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "202":
          description: The outcome of this request, or that it is still running.
          content:
            application/json:
              schema:
                type: object
                required: [machine, state]
                properties:
                  machine:
                    type: string
                  state:
                    type: string
                    enum: [requested, running, paused]
                    description: requested is accepted and still running; running came back up; paused captured the Snapshot but stayed paused, with the Snapshot intact.
                  snapshot:
                    $ref: "#/components/schemas/Snapshot"
                  frozen_ms:
                    type: integer
                    format: int64
                    description: The pause-to-running window in milliseconds, on state running.
                  error:
                    type: string
                    description: Why the Machine stayed paused, on state paused.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The Machine was not running, or the capture did not happen.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/machines/{id}/snapshots/{snapshot}:
    delete:
      tags: [snapshots]
      operationId: deleteSnapshot
      summary: Delete one Snapshot of a Machine
      parameters:
        - $ref: "#/components/parameters/MachineID"
        - name: snapshot
          in: path
          required: true
          schema:
            type: string
      responses:
        "202":
          description: Accepted for serialized deletion. Poll the Machine for observed Snapshot absence; a fresh Machine error reports Host refusal, including active resume points or dependent Forks.
          content:
            application/json:
              schema:
                type: object
                required: [machine, snapshot]
                properties:
                  machine:
                    type: string
                  snapshot:
                    type: string
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/machines/{id}/connections:
    get:
      tags: [machines]
      operationId: listMachineConnections
      summary: List directed Machine TCP permissions
      description: Requires Manage access to the source. Includes retained revocations, ordered by destination and port.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "200":
          description: Durable connection intent and acknowledged state.
          content:
            application/json:
              schema:
                type: array
                items: {$ref: "#/components/schemas/MachineConnection"}
        "401": {$ref: "#/components/responses/Unauthenticated"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "503":
          description: Connection management is unavailable.

  /v1/machines/{id}/connections/{destination}/tcp/{port}:
    parameters:
      - $ref: "#/components/parameters/MachineID"
      - name: destination
        in: path
        required: true
        schema: {type: string}
      - $ref: "#/components/parameters/Port"
    put:
      tags: [machines]
      operationId: putMachineConnection
      summary: Allow one directed Machine TCP connection
      description: |
        Requires Manage access to both distinct Machines in the same Tenant.
        Both must be running on the same Host. An unchanged intent keeps its
        revision. No address, TAP, Host, or metadata is accepted as authority.
        Pending means accepted; applied means this revision was acknowledged
        by the Host. Pausing removes active access; resuming the same Machine
        identity can restore allowed policy. A Fork never inherits permission.
      responses:
        "200":
          description: Existing acknowledged or failed intent.
          content:
            application/json:
              schema: {$ref: "#/components/schemas/MachineConnection"}
        "202":
          description: Durable pending intent, including a deferred workflow start.
          content:
            application/json:
              schema: {$ref: "#/components/schemas/MachineConnection"}
        "401": {$ref: "#/components/responses/Unauthenticated"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "409":
          description: The endpoints are not running on the same Host.
        "422": {$ref: "#/components/responses/Unprocessable"}
        "503":
          description: Connection management is unavailable.
    delete:
      tags: [machines]
      operationId: deleteMachineConnection
      summary: Revoke one directed Machine TCP connection
      description: |
        Retains a monotonic revocation revision. Requires Manage access to the
        source and any surviving destination. A stopped or missing destination
        does not prevent revocation. Delayed old acknowledgments cannot change
        the newer intent. Revoking also stops existing matching streams.
      responses:
        "200":
          description: Existing acknowledged revocation.
          content:
            application/json:
              schema: {$ref: "#/components/schemas/MachineConnection"}
        "202":
          description: Durable pending revocation.
          content:
            application/json:
              schema: {$ref: "#/components/schemas/MachineConnection"}
        "401": {$ref: "#/components/responses/Unauthenticated"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "422": {$ref: "#/components/responses/Unprocessable"}
        "503":
          description: Connection management is unavailable.

  /v1/machines/{id}/ports:
    get:
      tags: [machines]
      operationId: listPorts
      summary: List what the Tenant said about a Machine's ports
      description: |
        The HTTPS Front Door (ticket 041) serves every Machine at
        `https://<id>.runin.eu`, which forwards to the default port or, with
        none set, the first of 80, 3000 and 8080 that answers, and every port
        at `https://<id>-<port>.runin.eu`. Both are private unless the port is
        public: a request needs an API key whose Member may use the Machine as
        a bearer token, or a share link.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "200":
          description: The ports, lowest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PortList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/machines/{id}/ports/{port}:
    put:
      tags: [machines]
      operationId: putPort
      summary: Make a port public or private, or the default
      description: |
        Updates only supplied fields; omitted fields retain their current values.
        A new port starts private and is not the default. Explicit false clears
        the corresponding setting. Making a port the default clears that flag
        on the other ports, without changing any port's visibility.
      parameters:
        - $ref: "#/components/parameters/MachineID"
        - $ref: "#/components/parameters/Port"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PutPortRequest"
      responses:
        "200":
          description: What the Front Door now does with the port.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MachinePort"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }
    delete:
      tags: [machines]
      operationId: deletePort
      summary: Forget a port, which makes it private and not the default
      parameters:
        - $ref: "#/components/parameters/MachineID"
        - $ref: "#/components/parameters/Port"
      responses:
        "204":
          description: Forgotten.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/machines/{id}/shares:
    get:
      tags: [machines]
      operationId: listShares
      summary: List a Machine's share links
      description: "Revoked and expired links are listed too. The URL is never shown again after the create."
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "200":
          description: The share links, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShareList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }
    post:
      tags: [machines]
      operationId: createShare
      summary: Make a share link to one port of a Machine
      description: |
        A signed, expiring URL that opens one port of this Machine to anyone
        holding it, until it expires or is revoked. Opening it sets a cookie
        for that hostname only and redirects to the same URL without the token.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateShareRequest"
      responses:
        "201":
          description: The link. `url` is shown here and never again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Share"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/machines/{id}/shares/{share}:
    delete:
      tags: [machines]
      operationId: revokeShare
      summary: Revoke a share link
      description: "The Front Doors cache a check for at most 15 seconds, so a revoked link stops working within that."
      parameters:
        - $ref: "#/components/parameters/MachineID"
        - name: share
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Revoked. Revoking a revoked link is not an error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Share"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/machines/{id}/machine-shares:
    get:
      tags: [machines]
      operationId: listMachineShares
      summary: The Machine's Machine shares
      description: |
        Who the Machine is shared with (ticket 133): the Members that may see
        and enter it without managing it. A share for `tenant` is one for
        every Member of the Tenant. These are not the share links under
        `/v1/machines/{id}/shares`, which open one port to whoever holds the
        link.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      responses:
        "200":
          description: The Machine shares, oldest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MachineShareList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }
    post:
      tags: [machines]
      operationId: createMachineShare
      summary: Share the Machine with a Member, or with the whole Tenant
      description: |
        Manage on the Machine is required. Sharing twice keeps one share.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [member]
              properties:
                member:
                  type: string
                  description: A live Member's name, or `tenant` for every Member of the Tenant.
      responses:
        "201":
          description: The Machine share.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MachineShare"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/machines/{id}/machine-shares/{share}:
    delete:
      tags: [machines]
      operationId: deleteMachineShare
      summary: Remove one Machine share
      parameters:
        - $ref: "#/components/parameters/MachineID"
        - name: share
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: The share is gone.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/machines/{id}/transfer:
    post:
      tags: [machines]
      operationId: transferMachine
      summary: Hand the Machine to another Member
      description: |
        A Tenant admin's operation (ticket 133). The Machine's Snapshots,
        share links and Machine shares stay on it; only the owner changes.
      parameters:
        - $ref: "#/components/parameters/MachineID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [member]
              properties:
                member:
                  type: string
                  description: The new owner's name, a live Member of the Tenant.
      responses:
        "200":
          description: The Machine with its new owner.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Machine"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/templates:
    get:
      tags: [templates]
      operationId: listTemplates
      summary: This Tenant's Templates, one row per version
      responses:
        "200":
          description: The Template versions, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [templates]
      operationId: createTemplate
      summary: Build a Template from a Dockerfile
      description: |
        The Dockerfile is the request body, as text. A Tenant's build runs in a
        short-lived builder Machine of that Tenant's (ADR 0013); only an admin
        key's build, for platform Templates, runs on the Host. It takes minutes, so this returns as soon as the work is accepted: the
        version is named in the answer and its state is followed on
        GET /v1/templates/{name}/versions.

        `host` builds on one named Host instead of letting Placement choose,
        and is admin-only. A Template version lives only on the Host that built
        it until replication lands, so pre-warming a Host Group means building
        on each Host in it.

        Template names are unique within a Tenant. Another Tenant may use the
        same name. Platform Template names are reserved and return `409` to
        customer publication. Selectors always take names, never storage IDs. A Tenant has one Dockerfile build at a time; a second one
        while the first is `building` is a `409` naming it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateTemplateRequest"
      responses:
        "202":
          description: The build was accepted and a workflow is running it.
          headers:
            Location:
              description: The Template's versions URL, where the state is followed.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StartedTemplate"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/templates/{name}/versions:
    get:
      tags: [templates]
      operationId: listTemplateVersions
      summary: The versions of one Template
      parameters:
        - $ref: "#/components/parameters/TemplateName"
      responses:
        "200":
          description: The versions, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/templates/{name}/versions/{version}:
    delete:
      tags: [templates]
      operationId: deleteTemplateVersion
      summary: Remove one version of a Template
      description: |
        Retention. A Template that gains a version after every default-branch
        job needs the old ones to go, and this is what removes one: the
        dataset on every Host that holds it, and its Golden Snapshot with it.

        It refuses with `409` while a Machine is still a clone of the version.
        A Machine's disk exists only as a delta against the version it was
        created from, so destroying the version would take that Machine's disk
        with it. The problem document names what is in the way; destroy those
        first.
      parameters:
        - $ref: "#/components/parameters/TemplateName"
        - name: version
          in: path
          required: true
          schema: { type: string }
          description: The version to remove.
      responses:
        "204":
          description: The version is gone.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/templates/{name}/golden:
    post:
      tags: [templates]
      operationId: captureGolden
      summary: Capture a Golden Snapshot of a Template version
      description: |
        Boots the version once on a Host and captures it, so that a Machine
        created from it starts in well under a second instead of booting. The
        capture runs on a Host and takes minutes, so this returns as soon as
        the work is accepted.

        A Golden Snapshot restores only in the shape (vCPUs and memory) it was
        captured in, so a Host holds one per shape. Without `size` every size
        preset is captured, one after the other; a Template build does the
        same on its own once the version is ready. With `size` only that shape
        is captured, which is how a custom size is made to restore.
      parameters:
        - $ref: "#/components/parameters/TemplateName"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                version:
                  type: string
                  description: Which version to capture. Empty is the newest built.
                size:
                  $ref: "#/components/schemas/SizeRequest"
      responses:
        "202":
          description: The capture was accepted and a workflow is running it.
          headers:
            Location:
              description: The Template's versions URL, where the state is followed.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StartedTemplate"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/tenants:
    get:
      tags: [tenants]
      operationId: listTenants
      summary: Every Tenant (admin)
      responses:
        "200":
          description: The Tenants, oldest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [tenants]
      operationId: createTenant
      summary: Create a Tenant in a named Host Group (admin)
      description: |
        A Tenant is placed in exactly one Host Group and the Host Group is
        required here. The retired `internal_networking` field accepts only false. The
        same request twice answers 200 with the existing Tenant; a request that
        would change an existing Tenant is a 409 pointing at PATCH. Minting an API key for a name nobody has used also
        creates a Tenant, but it cannot name a Host Group, and an empty Host
        Group means any of them.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateTenantRequest"
      responses:
        "200":
          description: The Tenant already existed in this Host Group.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "201":
          description: The Tenant was created.
          headers:
            Location:
              description: The new Tenant's URL.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/tenants/{id}:
    get:
      tags: [tenants]
      operationId: getTenant
      summary: One Tenant
      description: |
        A key reads its own Tenant; an admin key reads any. Another Tenant's is
        a 404, as for every other resource, so a key cannot probe which Tenant
        IDs exist.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The Tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [tenants]
      operationId: updateTenant
      summary: Change a Tenant's settings
      description: |
        Admin keys only, including on one's own Tenant. Only the fields present
        in the body are changed.

        The retired `internal_networking` field accepts false for migration.
        A request for true is rejected with 422. Use directed Machine connections.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateTenantRequest"
      responses:
        "200":
          description: The Tenant as it now is.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/keys:
    get:
      tags: [keys]
      operationId: listKeys
      summary: This Tenant's API keys
      description: |
        The secret itself is never returned again; only its prefix is.
        Without scope=tenant the answer is the caller's own keys; ?scope=tenant
        is a Tenant admin's whole-Tenant list, with a Member per key.
      parameters:
        - name: scope
          in: query
          required: false
          description: |
            "tenant" lists every key of the Tenant and needs a Tenant admin;
            without it the answer is the caller's own keys.
          schema:
            type: string
      responses:
        "200":
          description: The keys, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KeyList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [keys]
      operationId: createKey
      summary: Create an API key
      description: |
        The secret is in the answer and nowhere else: it is stored only as a
        SHA-256 hash, so a key that is lost is revoked and replaced.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateKeyRequest"
      responses:
        "201":
          description: The key was created. This is the only sight of its secret.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreatedKey"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/keys/{id}:
    delete:
      tags: [keys]
      operationId: revokeKey
      summary: Revoke an API key
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The key is revoked. Revoking a revoked key is not an error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIKey"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/keys/ssh:
    get:
      tags: [keys]
      operationId: listSSHKeys
      summary: List the Tenant's SSH public keys
      description: |
        The keys the SSH Front Door accepts for this Tenant's Machines
        (ticket 040). An admin key may name another Tenant with `?tenant=`.
        Without scope=tenant the answer is the caller's own keys; ?scope=tenant
        is a Tenant admin's whole-Tenant list, with a Member per key.
      parameters:
        - name: scope
          in: query
          required: false
          description: |
            "tenant" lists every key of the Tenant and needs a Tenant admin;
            without it the answer is the caller's own keys.
          schema:
            type: string
        - name: tenant
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: The keys, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SSHKeyList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "501": { $ref: "#/components/responses/NotImplemented" }
    post:
      tags: [keys]
      operationId: createSSHKey
      summary: Register an SSH public key
      description: |
        One line of an OpenSSH `authorized_keys` file. The SSH Front Door finds
        the Tenant from the key a client offers, so a key can be registered by
        one Tenant only; a second registration is 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateSSHKeyRequest"
      responses:
        "201":
          description: The key is registered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SSHKey"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/keys/ssh/{id}:
    delete:
      tags: [keys]
      operationId: deleteSSHKey
      summary: Remove an SSH public key
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The key is removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SSHKey"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/members:
    get:
      tags: [members]
      operationId: listMembers
      summary: The Tenant's Members, with their roles
      description: Every Member of the caller's Tenant, oldest first (ticket 133).
      responses:
        "200":
          description: The live Members.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MemberList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/members/{name}/role:
    put:
      tags: [members]
      operationId: setMemberRole
      summary: Change a Member's role
      description: |
        A Tenant admin's operation. The role is `admin` or `member`; demoting
        the Tenant's last admin is refused.
      parameters:
        - name: name
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  type: string
                  enum: [admin, member]
      responses:
        "200":
          description: The Member with their new role.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Member"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/members/{name}:
    delete:
      tags: [members]
      operationId: removeMember
      summary: Remove a Member
      description: |
        A Tenant admin's operation, run as a workflow (ticket 133): the
        Member's SSH keys are deleted, their tokens revoked, their pending
        invites cancelled, their Machines pass to the removing admin, the
        Machine shares naming them are deleted and their name is freed. The
        last Tenant admin cannot be removed. If one of their tokens was used
        in the last 24 hours the removal answers 409 unless `force` is set. A
        second caller whose request joins a removal already running is
        answered with the first caller's result: the Machines went to whoever
        ran the first.
      parameters:
        - name: name
          in: path
          required: true
          schema:
            type: string
        - name: force
          in: query
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: The Member is removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Member"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/invites:
    get:
      tags: [members]
      operationId: listInvites
      summary: The Tenant's pending invites
      description: Every invite of the caller's Tenant that is not used and
        not cancelled, oldest first (ticket 135); an expired one stays listed
        and keeps its name until an admin cancels it. A Tenant admin's
        operation.
      responses:
        "200":
          description: The pending invites.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InviteList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "501": { $ref: "#/components/responses/NotImplemented" }
    post:
      tags: [members]
      operationId: createInvite
      summary: Invite a Member
      description: |
        A Tenant admin's operation (ticket 135). Mints a one-time code, stores
        only its hash and answers the code once. The pending invite reserves
        its name; whether the name is also free among live Members is checked
        here and again when the code is redeemed. Default expiry 24 hours,
        longest seven days.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, role]
              properties:
                name:
                  type: string
                  description: 1 to 32 lowercase letters, digits or dashes;
                    not `tenant` and not a Member's name.
                role:
                  type: string
                  enum: [admin, member]
                ttl:
                  type: string
                  description: A duration such as `24h`. Default 24 hours,
                    longest 168h.
      responses:
        "201":
          description: The invite, with its code. The code is shown once.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InviteCreated"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/invites/{id}:
    delete:
      tags: [members]
      operationId: cancelInvite
      summary: Cancel a pending invite
      description: A Tenant admin's operation (ticket 135). The invite's row
        goes and its name is free. Cancelling one that was redeemed already is
        refused.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: The invite is cancelled.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplemented" }

  /v1/hosts:
    get:
      tags: [hosts]
      operationId: listHosts
      summary: Every Host and what it has left
      description: Admin keys only. This is the fleet view, not a Tenant's.
      responses:
        "200":
          description: The Hosts, by Host Group then name.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HostList"
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        An API key from `POST /v1/keys`, or the bootstrap admin key that
        `fsm-control init` prints once. Keys are 32 random bytes in base64url
        behind an `fsm_v1_` marker, and are stored only as a SHA-256 hash.

  parameters:
    Port:
      name: port
      in: path
      required: true
      description: A guest TCP port.
      schema:
        type: integer
        minimum: 1
        maximum: 65535
    MachineID:
      name: id
      in: path
      required: true
      description: A Machine ID, `m-` and twelve base32 characters.
      schema:
        type: string
        pattern: "^m-[a-z2-7]{12}$"
    TemplateName:
      name: name
      in: path
      required: true
      schema:
        type: string

  responses:
    BadRequest:
      description: The request body or its parameters could not be read.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Unauthenticated:
      description: No API key, or one that is unknown or revoked.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Forbidden:
      description: The key is valid but may not do this.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    NotFound:
      description: No such resource for this Tenant.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Conflict:
      description: The request is at odds with what already exists.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Unprocessable:
      description: The request was read but asks for something that is not allowed.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    PoolExhausted:
      description: The Tenant's Pool has no room for this.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    RateLimited:
      description: This key has spent its burst.
      headers:
        Retry-After:
          description: Seconds until the next token.
          schema:
            type: integer
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    NotImplemented:
      description: The Control Plane cannot do this yet.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"

  schemas:

    MachineConnection:
      type: object
      required: [id, tenant, source_machine, destination_machine, host, port, revision, allowed, state]
      properties:
        id: {type: string}
        tenant: {type: string}
        source_machine: {type: string}
        destination_machine: {type: string}
        host: {type: string}
        port: {type: integer, minimum: 1, maximum: 65535}
        revision: {type: integer, minimum: 1, format: int64}
        allowed: {type: boolean}
        state: {type: string, enum: [pending, applied, revoked, failed]}


    UsageHistory:
      type: object
      required: [tenant, from, to, group, metering_started_at, complete, buckets]
      properties:
        tenant: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        group: { type: string, enum: [day, month] }
        metering_started_at: { type: string, format: date-time }
        complete: { type: boolean }
        buckets:
          type: array
          items: { $ref: '#/components/schemas/UsageBucket' }
    UsageBucket:
      type: object
      required: [from, to, vcpu_seconds, memory_gib_seconds, disk_gib_hours, unknown_machine_seconds]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        vcpu_seconds: { type: number, minimum: 0 }
        memory_gib_seconds: { type: number, minimum: 0 }
        disk_gib_hours: { type: number, minimum: 0 }
        unknown_machine_seconds: { type: number, minimum: 0 }

    ResourceUsage:
      type: object
      required: [used, unit, limit, remaining, limit_status]
      properties:
        used:
          type: [integer, "null"]
          format: int64
        unit:
          type: string
          enum: [vCPU, mCPU, MiB, GiB, Machines]
        limit:
          type: [integer, "null"]
          format: int64
        remaining:
          type: [integer, "null"]
          format: int64
          description: Only numeric for enforced quotas; negative means exceeded.
        limit_status:
          type: string
          enum: [enforced, unlimited, unconfigured, not_enforced, unavailable]
    StorageUsage:
      type: object
      description: Host-observed ZFS categories. Null means missing or stale; referenced bytes include shared blocks and are not an additional physical charge.
      required: [observed_at, complete, disk_allocated_bytes, disk_dataset_bytes, disk_snapshot_bytes, disk_referenced_bytes, memory_snapshot_bytes, guest_filesystem_used_bytes]
      properties:
        observed_at:
          type: string
          format: date-time
        complete:
          type: boolean
        disk_allocated_bytes:
          type: [integer, "null"]
          format: int64
        disk_dataset_bytes:
          type: [integer, "null"]
          format: int64
        disk_snapshot_bytes:
          type: [integer, "null"]
          format: int64
        disk_referenced_bytes:
          type: [integer, "null"]
          format: int64
        backup_metadata_bytes:
          type: [integer, "null"]
          format: int64
          minimum: 0
        memory_snapshot_bytes:
          type: [integer, "null"]
          format: int64
        guest_filesystem_used_bytes:
          type: [integer, "null"]
          format: int64
    TenantUsage:
      type: object
      required: [tenant, observed_at, accounting, resources, machines, notes]
      properties:
        storage:
          $ref: '#/components/schemas/StorageUsage'
        tenant:
          type: string
        observed_at:
          type: string
          format: date-time
        accounting:
          type: string
          const: allocation_ledger
        resources:
          type: object
          required: [cpu, cpu_guarantee, memory, disk, machine_count]
          additionalProperties:
            $ref: '#/components/schemas/ResourceUsage'
        machines:
          type: object
          description: Counts by lifecycle phase, plus reserved for claims before a Machine row exists. The Machine count limit is the machine_count resource.
          required: [running, paused]
          additionalProperties:
            type: integer
            format: int64
        notes:
          type: array
          items:
            type: string
    Tenant:
      type: object
      description: A customer account. Owns Machines, Templates and Secrets.
      required: [id]
      properties:
        id:
          type: string
        name:
          type: string
        host_group:
          type: string
          description: Where this Tenant's Machines are placed. Empty is any Host Group.
        suspended:
          type: boolean
        additional_disk_sizes_gib:
          $ref: "#/components/schemas/AdditionalDiskSizes"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CreateTenantRequest:
      type: object
      required: [id, host_group]
      properties:
        id:
          type: string
          description: The TenantID. No spaces, and none of / ? # %.
        name:
          type: string
        host_group:
          type: string
          description: The Host Group this Tenant's Machines are placed in.
        additional_disk_sizes_gib:
          $ref: "#/components/schemas/AdditionalDiskSizes"
        internal_networking:
          type: boolean
          deprecated: true
          enum: [false]
          default: false
          description: Retired compatibility field. True is rejected with 422.

    UpdateTenantRequest:
      type: object
      description: |
        Only the fields present are changed. A field that is present and false
        turns that setting off.
      properties:
        name:
          type: string
        host_group:
          type: string
        additional_disk_sizes_gib:
          $ref: "#/components/schemas/AdditionalDiskSizes"
        internal_networking:
          type: boolean
          deprecated: true
          enum: [false]
          description: False clears historical metadata for migration; true is rejected with 422.

    AdditionalDiskSizes:
      type: array
      uniqueItems: true
      description: |
        Exact operator-managed service disk capacities, in GiB. Each is 20 or
        above 30, and at most 200. Ordinary customer choices remain 10 and 30.
        Updating this list replaces it; an empty list clears the allowances.
        Only operator credentials may set these allowances.
      items:
        oneOf:
          - type: integer
            enum: [20]
          - type: integer
            minimum: 31
            maximum: 200

    Problem:
      type: object
      description: An RFC 7807 problem document.
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: A URI naming the kind of failure.
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
        errors:
          type: array
          description: One entry per field that was wrong.
          items:
            type: object
            required: [field, detail]
            properties:
              field:
                type: string
              detail:
                type: string

    Size:
      type: object
      description: What a Machine is allotted.
      required: [vcpus, mem_mib]
      properties:
        vcpus:
          type: integer
          minimum: 1
          maximum: 16
        mem_mib:
          type: integer
          format: int64
          minimum: 1
          maximum: 32768
        disk_gib:
          type: integer
          format: int64
          minimum: 0
          maximum: 200
          description: |
            0 inherits the Template's verified capacity. Customer choices are
            10 or 30 GiB; exact additional service capacities require an
            operator-managed Tenant allowance. Cannot shrink the Template.

    SizeRequest:
      description: |
        Either a preset name or explicit values. The presets are `small`
        (2 vCPU, 4096 MiB), `medium` (4 vCPU, 8192 MiB) and `large` (8 vCPU,
        16384 MiB); omitting it is `small`. Every Host holds a Golden Snapshot
        of each Template version in each preset, so a preset restores in well
        under a second and any other size cold-boots, saying so in
        `cold_boot`.
      oneOf:
        - type: string
          enum: [small, medium, large]
        - $ref: "#/components/schemas/Size"

    Machine:
      type: object
      description: |
        A Machine. The size and address are flattened rather than nested so
        that the fields the CLI prints are the fields the JSON has.
      required: [id, tenant, phase, template]
      properties:
        id:
          type: string
        tenant:
          type: string
        host:
          type: string
        phase:
          type: string
          enum: [creating, running, pausing, paused, interrupted, resuming, recovering, forking, destroying, destroyed, failed]
          description: |
            Interrupted preserves the disk, Snapshots and Identity after runtime
            loss. Compute remains allocated or uncertain until the Host confirms
            runtime cleanup. Recovering boots the preserved disk after explicit
            resume and admission on the current Host. Failed recovery returns to
            interrupted with an error; it does not delete persistent data.
        template:
          type: string
        template_id:
          type: string
          description: Immutable storage identifier of the source Template. The template field remains its logical name.
        version:
          type: string
        source:
          type: string
          description: "\"How the Machine came into being: golden, cold or fork.\""
        cold_boot:
          type: string
          description: Why it booted cold, when it did.
        parent:
          type: string
          description: The Machine this one was Forked from.
        class:
          $ref: "#/components/schemas/MachineClass"
        metadata:
          type: object
          additionalProperties:
            type: string
          description: |
            What the create passed in, handed straight back. This API mints a
            Machine's ID and refuses a body that carries one, so a client that
            needs its own name for a Machine puts it here and reads it back
            from a list. It is echoed, never interpreted.
        vcpus:
          type: integer
        mem_mib:
          type: integer
          format: int64
        disk_gib:
          type: integer
          format: int64
        address:
          type: string
        gateway:
          type: string
        tap:
          type: string
        backup:
          $ref: "#/components/schemas/BackupStatus"
        backups_enabled:
          type: [boolean, "null"]
          description: |
            The Tenant's requested backup preference (ticket 117). False means
            this Machine is opted out of automatic off-Host backups; the Host
            starts no new captures once its policy refresh observes it. This is
            a request, not an observation: `backup` reports what actually
            happened, and its history stays visible after an opt-out. Null is a
            Control Plane from before the preference existed.
        snapshots:
          type: array
          items:
            $ref: "#/components/schemas/Snapshot"
        error:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    BackupStatus:
      type: object
      required: [recovery_point_secs, observed_at, captured_age_secs, overdue, last_success, last_attempt]
      description: "Optional backup observation. Its absence means status is unavailable, not successful. Capture time measures the age of protected data; replication time is upload completion. A Machine opted out of backups never reads as overdue: no new captures are expected, so compliance is not applicable and `overdue` is null while the history stays visible."
      properties:
        recovery_point_secs:
          type: integer
          minimum: 1
        observed_at:
          type: string
          format: date-time
        captured_age_secs:
          type: [integer, "null"]
          format: int64
        overdue:
          type: [boolean, "null"]
        last_success:
          oneOf:
            - $ref: "#/components/schemas/BackupSuccess"
            - type: "null"
        last_attempt:
          oneOf:
            - $ref: "#/components/schemas/BackupAttempt"
            - type: "null"

    BackupSuccess:
      type: object
      required: [checkpoint_id, captured_at, replicated_at, kind]
      properties:
        checkpoint_id:
          type: string
        captured_at:
          type: string
          format: date-time
        replicated_at:
          type: string
          format: date-time
        kind:
          type: string
          enum: [disk, paused]
        snapshot:
          type: string
        manifest_version:
          type: string

    BackupAttempt:
      type: object
      required: [checkpoint_id, started_at, finished_at, state]
      properties:
        checkpoint_id:
          type: string
        started_at:
          type: string
          format: date-time
        finished_at:
          type: [string, "null"]
          format: date-time
        state:
          type: string
          enum: [running, succeeded, failed]
        error:
          type: string

    MachineList:
      type: object
      required: [machines]
      properties:
        machines:
          type: array
          items:
            $ref: "#/components/schemas/Machine"

    CreateMachineRequest:
      type: object
      required: [template]
      properties:
        template:
          type: string
        version:
          type: string
          description: Empty is the newest version the Host holds.
        size:
          $ref: "#/components/schemas/SizeRequest"
        class:
          $ref: "#/components/schemas/MachineClass"
        backups_enabled:
          type: boolean
          default: true
          description: |
            The requested backup preference for the new Machine (ticket 117).
            Omitted is true, which is what every Machine has until its Tenant
            says otherwise. A disposable Runner Machine passes false; a Service
            Machine keeps the default.
        metadata:
          type: object
          additionalProperties:
            type: string

    MachineClass:
      type: string
      enum: [flex, reserved]
      default: flex
      description: |
        The Machine's CPU class, chosen at create time (ticket 055). `flex`,
        the default, is guaranteed a share of each vCPU (a quarter unless the
        Host Group says otherwise) and bursts to all of its vCPUs when the
        Host has idle cores. `reserved` is guaranteed every vCPU it has and is
        never oversubscribed. Memory is the same for both: never
        oversubscribed. A Fork has its parent's class, and the class of a
        running Machine cannot be changed.

    ForkRequest:
      type: object
      properties:
        count:
          type: integer
          minimum: 1
          maximum: 32
          default: 1
        snapshot:
          type: string
          description: Which Snapshot to Fork from. Empty is the newest.
        backups_enabled:
          type: boolean
          description: |
            The children's requested backup preference (ticket 117). Omitted
            inherits the parent's current preference, read at the Fork; an
            explicit value overrides it either way.

    Snapshot:
      type: object
      required: [machine, id]
      properties:
        machine:
          type: string
        id:
          type: string
        disk:
          type: string
        mem_mib:
          type: integer
          format: int64
        kind:
          type: string
          enum: [pause, manual]
          description: pause is an automatic Snapshot a pause wrote (only the newest is kept); manual is one kept until deleted. Absent is a Snapshot from before the field and reads as pause.
        created_at:
          type: string
          format: date-time

    ExecRequest:
      type: object
      required: [cmd]
      properties:
        cmd:
          type: string
          description: The program. Nothing splits a string, so a pipeline is /bin/sh -c.
        args:
          type: array
          items:
            type: string
        env:
          type: object
          additionalProperties:
            type: string
        cwd:
          type: string
        user:
          type: string
          description: Who to be inside the Machine. Empty runs as the Machine's recorded login user.
        tty:
          type: boolean
          description: Give the command a terminal; its output then arrives as pty events.
        cols:
          type: integer
        rows:
          type: integer
        timeout_ms:
          type: integer
          format: int64
          description: Give up on the command after this long. 0 waits for it.

    ExecEvent:
      type: object
      description: One line of the NDJSON exec stream.
      required: [event]
      properties:
        event:
          type: string
          enum: [start, stdout, stderr, pty, end, error]
        pid:
          type: integer
          description: On a start event.
        data:
          type: string
          format: byte
          description: On a stdout, stderr or pty event.
        exit_code:
          type: integer
          description: On an end event.
        exited:
          type: boolean
          description: False on an end event when a signal ended the command.
        status:
          type: string
          description: The guest's own description of how the command ended.
        message:
          type: string
          description: On an error event.

    CommitRequest:
      type: object
      required: [template, version]
      properties:
        template:
          type: string
          description: The Template to publish into. Usually the Machine's own.
        version:
          type: string
          description: |
            The version to publish as. Required: a commit is idempotent on
            (template, version), so a caller retrying one has to be able to ask
            for the same version again. The Runner Controller names them
            `<yyyymmdd>-<sha>`.

    TemplateVersion:
      type: object
      required: [id, name, version]
      properties:
        id:
          type: string
          description: Immutable storage identifier. Existing Templates retain their old name as ID; new Templates use tpl- followed by a UUID. This is not a customer selector.
        name:
          type: string
          description: Name within the owning Tenant, or a reserved platform name.
        version:
          type: string
        tenant:
          type: string
        state:
          type: string
          enum: [building, ready, failed]
          description: Where this version's build has got to.
        golden_state:
          type: string
          enum: [none, capturing, ready, failed]
          description: Where the Golden Snapshot capture of this version has got to.
        error:
          type: string
          description: What the Host said about a build or a capture that failed.
        golden:
          type: boolean
          description: |
            Whether a Host really holds a Golden Snapshot of this version,
            which is what decides whether a Machine starts in a second. It
            comes from the Hosts' capacity reports, so it lags golden_state by
            up to one report after a capture.
        host:
          type: string
        golden_shapes:
          type: array
          items:
            type: string
          description: |
            The shapes some Host holds a Golden Snapshot of this version in,
            as `<vcpus>x<mem_mib>`, smallest first. A Machine of one of these
            shapes restores; any other size cold-boots.
        built_at:
          type: string
          format: date-time

    StartedTemplate:
      description: |
        A Template version whose build or capture has just been accepted, with
        the workflow doing it and where to watch.
      allOf:
        - $ref: "#/components/schemas/TemplateVersion"
        - type: object
          required: [workflow, status_url]
          properties:
            workflow:
              type: string
              description: The Temporal workflow ID doing the work.
            status_url:
              type: string
              description: Where to read the version's state.

    TenantList:
      type: object
      required: [tenants]
      properties:
        tenants:
          type: array
          items:
            $ref: "#/components/schemas/Tenant"

    TemplateList:
      type: object
      required: [templates]
      properties:
        templates:
          type: array
          items:
            $ref: "#/components/schemas/TemplateVersion"

    CreateTemplateRequest:
      type: object
      required: [name, dockerfile]
      properties:
        disk_gib:
          type: integer
          format: int64
          minimum: 0
          maximum: 200
          default: 10
          description: |
            Template root filesystem size in GiB. Omitted or 0 defaults to 10.
            Use 30 for a larger Template. An existing Template volume can grow
            but cannot shrink; earlier versions and Machines keep their size.
        name:
          type: string
        version:
          type: string
          description: Empty is a UTC timestamp.
        host:
          type: string
          description: |
            Build on this Host instead of letting Placement choose. Admin keys
            only.
        dockerfile:
          type: string
          description: The Dockerfile, as text.

    APIKey:
      type: object
      required: [id, tenant, prefix, created_at]
      properties:
        id:
          type: string
        tenant:
          type: string
        name:
          type: string
        prefix:
          type: string
          description: The first characters of the secret, which is how it is looked up.
        admin:
          type: boolean
        created_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time

    Whoami:
      type: object
      required: [tenant, key_id, key_prefix, admin]
      properties:
        tenant:
          type: string
        tenant_name:
          type: string
          description: Empty for a Tenant that has keys and no Tenant record, such as the bootstrap admin Tenant.
        host_group:
          type: string
        key_id:
          type: string
        key_name:
          type: string
        key_prefix:
          type: string
        admin:
          type: boolean
        member:
          type: object
          description: Who the key acts as (ticket 133). Absent on a Control
            Plane whose Store keeps no Members.
          properties:
            id:
              type: string
            name:
              type: string
            role:
              type: string
              enum: [admin, member]

    KeyList:
      type: object
      required: [keys]
      properties:
        keys:
          type: array
          items:
            $ref: "#/components/schemas/APIKey"

    CreateKeyRequest:
      type: object
      properties:
        name:
          type: string
        tenant:
          type: string
          description: Another Tenant's key. Admin keys only.
        admin:
          type: boolean
          description: Make an admin key. Admin keys only.

    CreatedKey:
      allOf:
        - $ref: "#/components/schemas/APIKey"
        - type: object
          required: [key]
          properties:
            key:
              type: string
              description: The secret, shown once.

    Host:
      type: object
      required: [host, group]
      properties:
        host:
          type: string
        group:
          type: string
        vcpus:
          type: integer
        mem_mib:
          type: integer
          format: int64
        running_vcpus:
          type: integer
        running_mem_mib:
          type: integer
          format: int64
        paused_count:
          type: integer
        templates:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              version:
                type: string
              golden:
                type: boolean
              shapes:
                type: array
                description: The shapes the Host holds a usable Golden Snapshot in.
                items:
                  type: object
                  properties:
                    vcpus:
                      type: integer
                    mem_mib:
                      type: integer
                      format: int64
        cpu_template:
          type: string
          description: |
            The Firecracker CPU template the Host applies to every Machine, as
            `<name>@sha256:<16 hex>`; empty for none. A Snapshot restores only
            on a Host applying the one it was taken with.
        reported_at:
          type: string
          format: date-time

    HostList:
      type: object
      required: [hosts]
      properties:
        hosts:
          type: array
          items:
            $ref: "#/components/schemas/Host"

    SSHKey:
      type: object
      required: [id, tenant, public_key, fingerprint, created_at]
      properties:
        id:
          type: string
        tenant:
          type: string
        name:
          type: string
        public_key:
          type: string
          description: The key without its comment, `<type> <base64>`.
        fingerprint:
          type: string
          description: OpenSSH's SHA256 fingerprint, `SHA256:...`.
        created_at:
          type: string
          format: date-time

    SSHKeyList:
      type: object
      required: [keys]
      properties:
        keys:
          type: array
          items:
            $ref: "#/components/schemas/SSHKey"

    Member:
      type: object
      required: [id, name, role]
      properties:
        id:
          type: string
        name:
          type: string
          description: Unique among the Tenant's live Members; the name a
            removed Member frees.
        role:
          type: string
          enum: [admin, member]
        created_at:
          type: string
          format: date-time

    MemberList:
      type: object
      required: [members]
      properties:
        members:
          type: array
          items:
            $ref: "#/components/schemas/Member"

    Invite:
      type: object
      required: [id, name, role, created_at, expires_at]
      properties:
        id:
          type: string
        name:
          type: string
          description: The name the redeemer's Member gets.
        role:
          type: string
          enum: [admin, member]
        created_by:
          type: string
          description: The Member id of the admin who created the invite.
        created_by_name:
          type: string
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          description: How long the code still works.

    InviteCreated:
      type: object
      required: [id, name, role, created_at, expires_at, code]
      properties:
        id:
          type: string
        name:
          type: string
        role:
          type: string
          enum: [admin, member]
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        code:
          type: string
          description: The one-time code. It is shown here and never again;
            only its hash is stored.

    InviteList:
      type: object
      required: [invites]
      properties:
        invites:
          type: array
          items:
            $ref: "#/components/schemas/Invite"

    MachineShare:
      type: object
      required: [id, machine, member, created_at]
      properties:
        id:
          type: string
        machine:
          type: string
        member:
          type: string
          description: The Member's name, or `tenant` for every Member of the Tenant.
        created_by:
          type: string
          description: The Member that created the share.
        created_at:
          type: string
          format: date-time

    MachineShareList:
      type: object
      required: [shares]
      properties:
        shares:
          type: array
          items:
            $ref: "#/components/schemas/MachineShare"

    CreateSSHKeyRequest:
      type: object
      required: [public_key]
      properties:
        public_key:
          type: string
          description: One line of an authorized_keys file, without options.
        name:
          type: string
          description: Defaults to the key's comment.
        tenant:
          type: string
          description: Register for another Tenant. Admin keys only.

    MachinePort:
      type: object
      required: [machine, port, public, default]
      properties:
        machine:
          type: string
        port:
          type: integer
        public:
          type: boolean
          description: Served on the HTTPS Front Door with no credential.
        default:
          type: boolean
          description: The port behind `https://<machine>.runin.eu`.
        updated_at:
          type: string
          format: date-time

    PortList:
      type: object
      required: [ports]
      properties:
        ports:
          type: array
          items:
            $ref: "#/components/schemas/MachinePort"

    PutPortRequest:
      type: object
      properties:
        public:
          type: boolean
          description: Omit to preserve visibility; false makes this port private without revoking share links.
        default:
          type: boolean
          description: Omit to preserve the default route; true selects this port without changing visibility.

    Share:
      type: object
      required: [id, tenant, machine, port, created_at, expires_at]
      properties:
        id:
          type: string
        tenant:
          type: string
        machine:
          type: string
        port:
          type: integer
          description: The guest port the link opens; 0 is the default route.
        name:
          type: string
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
        url:
          type: string
          description: Only in the answer to a create.

    ShareList:
      type: object
      required: [shares]
      properties:
        shares:
          type: array
          items:
            $ref: "#/components/schemas/Share"

    CreateShareRequest:
      type: object
      properties:
        port:
          type: integer
          minimum: 0
          maximum: 65535
        name:
          type: string
        ttl_seconds:
          type: integer
          format: int64
          description: A day when absent; between 60 seconds and 30 days.