openapi: 3.0.3
info:
  title: Create-a-Container API v1
  description: |
    JSON-only REST API for the React SPA and external automation.
    Co-exists with the legacy `/` (HTML) and `/apikeys` (mixed) routes during the React migration.

    ## Authentication

    Two mechanisms are supported:

    - **Session cookie** — established by `POST /api/v1/auth/login`. Requires a CSRF token (see below) on every state-changing request.
    - **API key** — `Authorization: Bearer <key>`. CSRF is skipped for Bearer-authenticated requests.

    ## CSRF (session auth only)

    Uses the synchroniser-token pattern (the token is stored in the session).

    1. `GET /api/v1/csrf-token` → returns `{ "data": { "csrfToken": "..." } }`. The token is bound to your session and stays valid for its lifetime, so fetch it once and reuse it.
    2. Send the token in the `X-CSRF-Token` header on every `POST`/`PUT`/`PATCH`/`DELETE`.

    ## Response envelope

    Success: `{ "data": <payload>, "meta"?: {...} }`.
    Error:   `{ "error": { "code": "...", "message": "...", "fields"?: {...} } }`.

    ## Common errors (any endpoint)

    Not repeated on every operation below:

    - `401 unauthorized` — missing/invalid session or API key (any authenticated endpoint).
    - `403 forbidden` — admin-only endpoint called by a non-admin.
    - `403 csrf_invalid` — missing/invalid `X-CSRF-Token` on a state-changing request under session auth.
    - `409 conflict` — unique-constraint violation (`fields` names the offending columns).
    - `422 validation_failed` — model-level validation failure (`fields` maps column → message).
    - `429` — rate limited (repeated 4xx/5xx responses trigger a temporary block).
    - `500 internal_error` — unhandled server error.
  version: 1.1.0
servers:
  - url: /api/v1

tags:
  - name: Auth
  - name: Sites
  - name: Containers
  - name: Nodes
  - name: Agents
  - name: Notifications
  - name: Jobs
  - name: External Domains
  - name: Users
  - name: Groups
  - name: API Keys
  - name: Settings
  - name: Resource Requests
  - name: Meta

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
    SessionCookie:
      type: apiKey
      in: cookie
      name: connect.sid
  headers:
    XCSRFToken:
      description: CSRF token returned by GET /csrf-token. Required for state-changing requests under session auth.
      schema: { type: string }
  responses:
    NotFound:
      description: Resource not found
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Forbidden:
      description: Forbidden
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    BadRequest:
      description: 'Invalid request (code: invalid_request)'
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            fields:
              type: object
              additionalProperties: { type: string }
    DataWrap:
      type: object
      required: [data]
      properties:
        data: {}
    Notification:
      type: object
      properties:
        id: { type: integer }
        source: { type: string, description: Emitter of the event, e.g. lxc-oomd }
        severity: { type: string, enum: [info, warning, critical] }
        node: { type: string, nullable: true, description: Hypervisor node name }
        ctid: { type: string, nullable: true, description: Container id on the hypervisor (CTID/VMID) }
        owner: { type: string, nullable: true, description: Owning user (Users.uid); drives per-user visibility }
        action: { type: string, nullable: true, description: 'freeze | kill | bump | quarantine | detect | ...' }
        message: { type: string }
        evidence: { type: object, nullable: true, additionalProperties: true }
        eventAt: { type: string, format: date-time, nullable: true, description: When the event happened on the node }
        acknowledgedAt: { type: string, format: date-time, nullable: true }
        acknowledgedBy: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    Site:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        internalDomain: { type: string }
        dhcpRange: { type: string }
        subnetMask: { type: string }
        gateway: { type: string }
        dnsForwarders: { type: string }
        externalIp: { type: string, nullable: true }
        nodeCount:
          type: integer
          description: Number of nodes in the site. Present on list/show responses; absent from create/update responses.
    SiteInput:
      type: object
      properties:
        name: { type: string }
        internalDomain: { type: string }
        dhcpRange: { type: string }
        subnetMask: { type: string }
        gateway: { type: string }
        dnsForwarders: { type: string }
        externalIp: { type: string, nullable: true }
    Container:
      type: object
      properties:
        id: { type: integer }
        hostname: { type: string }
        owner: { type: string, description: Username of the user who created the container }
        collaborators:
          type: array
          items: { type: string }
          description: Usernames the container is shared with
        containerId: { type: string, nullable: true, description: Provider container id — Proxmox VMID or Docker container id (null until provisioned) }
        status: { $ref: '#/components/schemas/ContainerStatus' }
        template: { type: string }
        ipv4Address: { type: string, nullable: true }
        macAddress: { type: string, nullable: true }
        creationJobId: { type: integer, nullable: true }
        entrypoint: { type: string, nullable: true }
        environmentVars:
          type: object
          additionalProperties: { type: string }
        nvidiaRequested: { type: boolean }
        sshPort: { type: integer, nullable: true, description: 'External port of the TCP service on internal port 22, if any' }
        sshHost: { type: string, nullable: true, description: 'Hostname to use with sshPort when connecting over SSH, falling back to the site external IP' }
        httpEntries:
          type: array
          description: One entry per HTTP service
          items:
            type: object
            properties:
              port: { type: integer }
              externalUrl: { type: string, nullable: true }
        lastAccessedAt:
          type: string
          format: date-time
          nullable: true
          description: >-
            Most recent proxy-reported access across the container's services
            (max of the services' lastAccessedAt). Null when never accessed.
        nodeName: { type: string, nullable: true }
        nodeApiUrl: { type: string, nullable: true }
        services:
          type: array
          items: { $ref: '#/components/schemas/ContainerService' }
        createdAt: { type: string, format: date-time }
    ContainerService:
      type: object
      properties:
        id: { type: integer }
        type: { type: string, enum: [http, transport, dns] }
        internalPort: { type: integer }
        lastAccessedAt:
          type: string
          format: date-time
          nullable: true
          description: >-
            Set when the site proxy reports traffic to this service — at most
            once per 10 minutes per service. Null when never accessed.
        httpService:
          type: object
          nullable: true
          properties:
            id: { type: integer }
            externalHostname: { type: string }
            externalDomainId: { type: integer }
            backendProtocol: { type: string, enum: [http, https] }
            authRequired: { type: boolean }
            domain: { type: string, nullable: true }
        transportService:
          type: object
          nullable: true
          properties:
            id: { type: integer }
            protocol: { type: string, enum: [tcp, udp] }
            externalPort: { type: integer }
        dnsService:
          type: object
          nullable: true
          properties:
            id: { type: integer }
            recordType: { type: string, enum: [SRV] }
            dnsName: { type: string }
    ContainerStatus:
      type: string
      description: >-
        Live container status, resolved on read from Proxmox run-state and the
        create job.
        running = online in Proxmox;
        offline = exists in Proxmox but stopped;
        creating = no Proxmox VM yet, active create job;
        failed = no Proxmox VM, create job failed;
        missing = no Proxmox VM, create succeeded or no create job;
        unknown = Proxmox unreachable / no node credentials.
      enum:
        - running
        - offline
        - creating
        - failed
        - missing
        - unknown
    ServiceInput:
      type: object
      required: [type, internalPort]
      properties:
        type:
          type: string
          enum: [http, https, tcp, udp, srv]
          description: >-
            http/https create an HTTP service (https sets backendProtocol to
            https); srv creates a DNS SRV service; tcp/udp create a transport
            service with an auto-assigned external port.
        internalPort: { type: integer }
        externalHostname: { type: string, description: Required for http/https }
        externalDomainId: { type: integer, description: Required for http/https }
        dnsName: { type: string, description: Required for srv }
        authRequired: { type: boolean }
    ServiceUpdate:
      allOf:
        - $ref: '#/components/schemas/ServiceInput'
        - type: object
          properties:
            id: { type: integer, description: Existing service id. Omit to create a new service. }
            deleted: { type: boolean, description: 'true deletes the service identified by `id`' }
    EnvVar:
      type: object
      properties:
        key: { type: string }
        value: { type: string }
    Node:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        nodeType: { type: string, enum: [proxmox, dummy, docker] }
        siteId: { type: integer }
        ipv4Address: { type: string, nullable: true }
        apiUrl:
          type: string
          nullable: true
          description: Proxmox API URL or Docker host, e.g. unix:///var/run/docker.sock, tcp://host:2375, http://host:2375, https://host:2376
        tokenId: { type: string, nullable: true }
        tlsVerify: { type: boolean, nullable: true }
        imageStorage: { type: string }
        volumeStorage: { type: string }
        networkBridge: { type: string }
        nvidiaAvailable: { type: boolean }
        hasSecret: { type: boolean }
    NodeInput:
      type: object
      required: [name]
      properties:
        name: { type: string }
        nodeType: { type: string, enum: [proxmox, dummy, docker], default: proxmox }
        ipv4Address: { type: string, nullable: true }
        apiUrl:
          type: string
          nullable: true
          description: Proxmox API URL or Docker host, e.g. unix:///var/run/docker.sock, tcp://host:2375, http://host:2375, https://host:2376. Required (and validated) when nodeType is docker.
        tokenId: { type: string, nullable: true }
        secret:
          type: string
          writeOnly: true
          description: Proxmox API token secret. Never returned (see `hasSecret`). On update, blank/omitted keeps the existing secret.
        tlsVerify: { type: boolean, nullable: true }
        imageStorage: { type: string, default: local }
        volumeStorage: { type: string, default: local-lvm }
        networkBridge: { type: string, default: vmbr0 }
        nvidiaAvailable: { type: boolean, default: false }
    Job:
      type: object
      properties:
        id: { type: integer }
        command: { type: string }
        status: { type: string, enum: [pending, running, success, failure, cancelled] }
        createdBy: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    JobStatusRow:
      type: object
      properties:
        id: { type: integer }
        jobId: { type: integer }
        output: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    ApiKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        keyPrefix: { type: string }
        description: { type: string, nullable: true }
        lastUsedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    User:
      type: object
      properties:
        uidNumber: { type: integer }
        uid: { type: string, description: Username }
        givenName: { type: string }
        sn: { type: string }
        cn: { type: string, description: Full name (derived from givenName + sn) }
        mail: { type: string, format: email }
        status: { type: string, description: 'Account status: `pending` until approved, `active` allows login' }
        groups:
          type: array
          description: Present when group memberships are loaded
          items:
            type: object
            properties:
              gidNumber: { type: integer }
              cn: { type: string }
              isAdmin: { type: boolean }
        isAdmin: { type: boolean }
    UserInput:
      type: object
      properties:
        uid: { type: string }
        givenName: { type: string }
        sn: { type: string }
        mail: { type: string, format: email }
        userPassword:
          type: string
          format: password
          writeOnly: true
          description: On update, blank/omitted keeps the existing password.
        status: { type: string, description: Defaults to `pending` on create }
        groupIds:
          type: array
          items: { type: integer }
          description: gidNumbers. When provided, replaces the user's group memberships.
    Group:
      type: object
      properties:
        gidNumber: { type: integer }
        cn: { type: string }
        isAdmin: { type: boolean }
        userCount:
          type: integer
          description: Present on list/show responses; absent from create/update responses.
    ExternalDomain:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        acmeEmail: { type: string, nullable: true }
        acmeDirectoryUrl: { type: string, nullable: true }
        cloudflareApiEmail: { type: string, nullable: true }
        siteId: { type: integer, nullable: true }
        site:
          type: object
          nullable: true
          properties:
            id: { type: integer }
            name: { type: string }
        authServer: { type: string, nullable: true }
        hasCloudflareApiKey: { type: boolean }
    ExternalDomainInput:
      type: object
      properties:
        name: { type: string }
        acmeEmail: { type: string, nullable: true }
        acmeDirectoryUrl: { type: string, nullable: true }
        cloudflareApiEmail: { type: string, nullable: true }
        cloudflareApiKey:
          type: string
          writeOnly: true
          description: Never returned (see `hasCloudflareApiKey`). On update, blank/omitted keeps the existing key.
        siteId: { type: integer, nullable: true }
        authServer: { type: string, nullable: true }
    Settings:
      type: object
      properties:
        smtpUrl: { type: string }
        smtpNoreplyAddress: { type: string }
        defaultContainerEnvVars:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              value: { type: string }
              description: { type: string }
        netboxUrl: { type: string }
        netboxToken: { type: string }
        bannerMessage: { type: string, description: 'Announcement banner shown at the top of the app (supports [text](url) links); blank disables it. Also surfaced unauthenticated via GET /health as `banner`.' }
    ResourceRequest:
      type: object
      properties:
        id: { type: integer }
        siteId: { type: integer }
        hostname: { type: string }
        username: { type: string }
        resourceType: { type: string, enum: [memory, swap, cpus, rootfs] }
        value: { type: integer, description: 'memory/swap in MB, rootfs in GB, cpus as a count' }
        status: { type: string, enum: [pending, approved, denied] }
        comment: { type: string, nullable: true }
        adminComment: { type: string, nullable: true }
        reviewedBy: { type: string, nullable: true, description: 'Reviewer username, or `system` for auto-approvals' }
        reviewedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        site:
          type: object
          nullable: true
          properties:
            id: { type: integer }
            name: { type: string }
    CollaboratorList:
      type: object
      properties:
        collaborators:
          type: array
          items: { type: string }

security:
  - BearerAuth: []
  - SessionCookie: []

paths:
  /csrf-token:
    get:
      operationId: get_csrf_token
      tags: [Meta]
      summary: Get a CSRF token
      security: []
      responses:
        '200':
          description: Token returned
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { csrfToken: { type: string } } } } }
  /health:
    get:
      operationId: get_health
      tags: [Meta]
      summary: Health check
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { status: { type: string }, isDev: { type: boolean }, oidcEnabled: { type: boolean }, banner: { type: string, nullable: true, description: Admin-configured announcement banner (null when unset) } } } } }
  /openapi.json:
    get:
      operationId: get_openapi_json
      tags: [Meta]
      summary: This OpenAPI specification (JSON)
      security: []
      responses:
        '200': { description: OpenAPI document, content: { application/json: {} } }
  /openapi.yaml:
    get:
      operationId: get_openapi_yaml
      tags: [Meta]
      summary: This OpenAPI specification (YAML)
      security: []
      responses:
        '200': { description: OpenAPI document, content: { text/yaml: {} } }

  /session:
    get:
      operationId: get_session
      tags: [Auth]
      summary: Current session
      responses:
        '200':
          description: Session payload
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { user: { type: string }, isAdmin: { type: boolean } } } } }
        '401': { description: Not authenticated, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  # POST /auth/dev (one-click dev login) exists only when NODE_ENV !== 'production'
  # and is intentionally undocumented here.
  /auth/login:
    post:
      operationId: login
      tags: [Auth]
      summary: Username/password login (disabled when OIDC SSO is enabled)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username: { type: string }
                password: { type: string, format: password }
                redirect: { type: string }
      responses:
        '200':
          description: Logged in
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { user: { type: string }, isAdmin: { type: boolean }, redirect: { type: string } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { description: 'Invalid credentials (code: invalid_credentials)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: 'OIDC enabled (code: oidc_enabled) — use SSO instead; or account not active (code: account_inactive)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /auth/oidc/login:
    get:
      operationId: oidc_login
      tags: [Auth]
      summary: Begin OIDC single sign-on (redirects to the identity provider)
      security: []
      parameters:
        - in: query
          name: redirect
          required: false
          schema: { type: string }
      responses:
        '302': { description: Redirect to the identity provider authorization endpoint }
        '404': { description: 'OIDC not configured (code: oidc_disabled)' }
  /auth/oidc/callback:
    get:
      operationId: oidc_callback
      tags: [Auth]
      summary: OIDC authorization-code callback (completes SSO and starts a session)
      security: []
      responses:
        '302': { description: 'Redirect to the post-login destination on success, or /login?oidc_error=<code> on failure' }
        '404': { description: 'OIDC not configured (code: oidc_disabled)' }
  /auth/logout:
    post:
      operationId: logout
      tags: [Auth]
      summary: Log out (clears the session; returns an IdP end-session URL when OIDC is enabled)
      security: []
      responses:
        '200':
          description: Logged out
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { loggedOut: { type: boolean }, logoutUrl: { type: string, nullable: true } } } } }
  /auth/register:
    post:
      operationId: register
      tags: [Auth]
      summary: Self-register (disabled when OIDC SSO is enabled)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [uid, givenName, sn, mail, userPassword]
              properties:
                uid: { type: string }
                givenName: { type: string }
                sn: { type: string }
                mail: { type: string, format: email }
                userPassword: { type: string, format: password }
                inviteToken: { type: string }
      responses:
        '201':
          description: Account created (`status` is `active` for the first user or invited users, otherwise `pending`)
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { uid: { type: string }, status: { type: string }, message: { type: string } } } } }
        '400': { description: 'invalid_request, invalid_invite, or email_mismatch', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: 'OIDC enabled (code: oidc_enabled) — self-registration disabled', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /auth/register/invite/{token}:
    get:
      operationId: validate_invite
      tags: [Auth]
      summary: Validate an invitation token
      security: []
      parameters: [{ in: path, name: token, required: true, schema: { type: string } }]
      responses:
        '200':
          description: Invite valid, returns email
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { email: { type: string } } } } }
        '403': { description: 'OIDC enabled (code: oidc_enabled)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: 'Invalid or expired invitation (code: invalid_invite)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /auth/password-reset/request:
    post:
      operationId: request_password_reset
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [usernameOrEmail]
              properties:
                usernameOrEmail: { type: string }
      responses:
        '200': { description: Generic OK (does not reveal account existence) }
        '400': { $ref: '#/components/responses/BadRequest' }
  /auth/password-reset/{token}:
    get:
      operationId: validate_password_reset_token
      tags: [Auth]
      security: []
      parameters: [{ in: path, name: token, required: true, schema: { type: string } }]
      responses:
        '200':
          description: Token valid, returns username
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { username: { type: string } } } } }
        '404': { description: 'Invalid or expired (code: invalid_token)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    post:
      operationId: reset_password
      tags: [Auth]
      security: []
      parameters: [{ in: path, name: token, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password, confirmPassword]
              properties:
                password: { type: string, description: Minimum 8 characters }
                confirmPassword: { type: string }
      responses:
        '200': { description: Password reset }
        '400': { description: 'invalid_request, mismatch, or weak_password', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: 'Invalid or expired (code: invalid_token)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /sites:
    get:
      operationId: list_sites
      tags: [Sites]
      summary: List sites
      responses:
        '200':
          description: Array of sites
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Site' } } } }
    post:
      operationId: create_site
      tags: [Sites]
      summary: Create a site (admin)
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SiteInput' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Site' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
  /sites/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: integer } }]
    get:
      operationId: get_site
      tags: [Sites]
      responses:
        '200':
          description: Site
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Site' } } }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: update_site
      tags: [Sites]
      summary: Update a site (admin). Full update — omitted optional fields are cleared.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SiteInput' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Site' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: delete_site
      tags: [Sites]
      summary: Delete a site (admin)
      responses:
        '204': { description: Deleted }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: 'Site still has nodes (code: has_nodes)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /sites/{siteId}/containers:
    get:
      operationId: list_containers
      tags: [Containers]
      parameters:
        - { in: path, name: siteId, required: true, schema: { type: integer } }
        - { in: query, name: hostname, schema: { type: string } }
        - { in: query, name: nodeId, schema: { type: integer }, description: Filter to a single node belonging to the site }
        - in: query
          name: user
          schema:
            type: array
            items: { type: string }
          description: >-
            Owner filter, sent in bracket notation (`user[0]=alice&user[1]=bob`)
            so it always parses as a list. Omitted/empty returns everything the
            caller may see: every container on the site for admins, or their own
            plus any shared with them for non-admins. A list of usernames
            returns those owners' containers for admins; for non-admins the list
            is intersected with what they may already see (own plus shared), so
            it can only narrow visibility.
      responses:
        '200':
          description: Array of containers
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Container' } } } }
        '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    post:
      operationId: create_container
      tags: [Containers]
      parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [hostname, template]
              properties:
                hostname: { type: string }
                template: { type: string, description: 'Docker image reference, or `custom` to use `customTemplate`' }
                customTemplate: { type: string, description: Image reference used when `template` is `custom` }
                entrypoint: { type: string }
                nvidiaRequested: { type: boolean }
                username:
                  type: string
                  description: '(Admin only) Create the container attributed to this existing user instead of the authenticated admin'
                collaborators:
                  type: array
                  items: { type: string }
                  description: Usernames to share the new container with (must exist)
                environmentVars:
                  type: array
                  items: { $ref: '#/components/schemas/EnvVar' }
                services:
                  type: object
                  description: Keyed map of services to create (keys are arbitrary)
                  additionalProperties: { $ref: '#/components/schemas/ServiceInput' }
      responses:
        '201':
          description: Creation job enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      containerId: { type: integer, description: Database id of the new container }
                      jobId: { type: integer }
                      hostname: { type: string }
                      status: { $ref: '#/components/schemas/ContainerStatus' }
        '400': { description: 'invalid_request or invalid_service', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: 'forbidden — non-admins may not create containers for other users', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: 'site_not_found, or user_not_found when a collaborator does not exist', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { description: 'No provisionable node available (code: no_node or no_nvidia_node)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sites/{siteId}/containers/new:
    get:
      operationId: get_new_container_form
      tags: [Containers]
      summary: Form bootstrap data (domains + NVIDIA flag)
      parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }]
      responses:
        '200':
          description: Bootstrap payload
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { siteId: { type: integer }, externalDomains: { type: array, items: { type: object } }, nvidiaAvailable: { type: boolean } } } } }
        '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sites/{siteId}/containers/metadata:
    get:
      operationId: get_image_metadata
      tags: [Containers]
      summary: Fetch Docker image metadata
      parameters:
        - { in: path, name: siteId, required: true, schema: { type: integer } }
        - { in: query, name: image, required: true, schema: { type: string } }
      responses:
        '200': { description: Image metadata }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: 'Image not found (code: image_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '502': { description: 'Registry lookup failed (code: registry_error)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sites/{siteId}/containers/{id}:
    parameters:
      - { in: path, name: siteId, required: true, schema: { type: integer } }
      - { in: path, name: id, required: true, schema: { type: integer } }
    get:
      operationId: get_container
      tags: [Containers]
      responses:
        '200':
          description: Container
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Container' } } }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: update_container
      tags: [Containers]
      summary: Update services/env/entrypoint; enqueues a restart job when needed (owner/admin)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                username:
                  type: string
                  description: '(Admin only) Reassign ownership of this container to the specified existing user'
                services:
                  type: object
                  description: >-
                    Keyed map of service changes (keys are arbitrary). Entries
                    with `deleted: true` remove the service `id`; entries with an
                    `id` may toggle `authRequired`; entries without an `id`
                    create a new service.
                  additionalProperties: { $ref: '#/components/schemas/ServiceUpdate' }
                environmentVars:
                  type: array
                  items: { $ref: '#/components/schemas/EnvVar' }
                  description: Full replacement set. Omitting it clears all user env vars (unless the request is restart-only).
                entrypoint: { type: string, nullable: true, description: Omitting/blank clears the entrypoint (unless the request is restart-only) }
                restart: { type: boolean, description: 'true forces a restart even with no config changes; alone, it performs a restart-only request' }
      responses:
        '200':
          description: Updated, optional restart job
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      containerId: { type: integer }
                      jobId: { type: integer, nullable: true, description: 'Restart job id, when a restart was enqueued' }
                      dnsWarnings: { type: array, items: { type: string } }
                      message: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view); non-admins may not reassign ownership', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: delete_container
      tags: [Containers]
      responses:
        '200':
          description: Deleted, with DNS cleanup warnings
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { deleted: { type: boolean }, dnsWarnings: { type: array, items: { type: string } } } } } }
        '403': { description: 'forbidden — only the owner/admin may delete (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: 'DB/Proxmox hostname mismatch — delete aborted (code: hostname_mismatch)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sites/{siteId}/containers/{id}/collaborators:
    parameters:
      - { in: path, name: siteId, required: true, schema: { type: integer } }
      - { in: path, name: id, required: true, schema: { type: integer } }
    get:
      operationId: list_container_collaborators
      tags: [Containers]
      summary: List users a container is shared with (owner/collaborator/admin)
      responses:
        '200':
          description: Collaborator list
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/CollaboratorList' } } }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      operationId: add_container_collaborator
      tags: [Containers]
      summary: Share a container with another user (owner/admin). Idempotent — sharing with an existing collaborator is a no-op.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username]
              properties: { username: { type: string } }
      responses:
        '201':
          description: Collaborator list
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/CollaboratorList' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { description: 'forbidden — only the owner/admin may share (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: 'user_not_found when the username does not exist', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { description: 'already_owner', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sites/{siteId}/containers/{id}/collaborators/{username}:
    delete:
      operationId: remove_container_collaborator
      tags: [Containers]
      summary: Stop sharing a container with a user (owner/admin)
      parameters:
        - { in: path, name: siteId, required: true, schema: { type: integer } }
        - { in: path, name: id, required: true, schema: { type: integer } }
        - { in: path, name: username, required: true, schema: { type: string } }
      responses:
        '200':
          description: Collaborator list
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/CollaboratorList' } } }
        '403': { description: 'forbidden — only the owner/admin may unshare (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: 'Container or collaborator not found', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /sites/{siteId}/nodes:
    parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }]
    get:
      operationId: list_nodes
      tags: [Nodes]
      responses:
        '200':
          description: Array of nodes
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Node' } } } }
        '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    post:
      operationId: create_node
      tags: [Nodes]
      summary: Create a node (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NodeInput' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Node' } } }
        '400': { description: 'invalid_node_type, or invalid_docker_host when nodeType is docker and apiUrl is not a valid Docker host', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { $ref: '#/components/responses/Forbidden' }
  /sites/{siteId}/nodes/import:
    post:
      operationId: import_nodes
      tags: [Nodes]
      summary: Bulk-import nodes + containers from a Proxmox cluster (admin)
      parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [apiUrl, username, password]
              properties:
                apiUrl: { type: string, format: uri }
                username: { type: string }
                password: { type: string }
                tlsVerify: { type: boolean }
      responses:
        '201':
          description: Imported
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { nodes: { type: array, items: { $ref: '#/components/schemas/Node' } }, importedContainerCount: { type: integer } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '502': { description: 'Proxmox import failed (code: import_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sites/{siteId}/nodes/{id}:
    parameters:
      - { in: path, name: siteId, required: true, schema: { type: integer } }
      - { in: path, name: id, required: true, schema: { type: integer } }
    get:
      operationId: get_node
      tags: [Nodes]
      responses:
        '200':
          description: Node
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Node' } } }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: update_node
      tags: [Nodes]
      summary: Update a node (admin). Full update — omitted fields are reset to defaults/null (except `secret`, which is kept when blank/omitted, and `nodeType`, which keeps its current value).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NodeInput' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Node' } } }
        '400': { description: 'invalid_node_type, or invalid_docker_host when nodeType is docker and apiUrl is not a valid Docker host', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: delete_node
      tags: [Nodes]
      summary: Delete a node (admin)
      responses:
        '204': { description: Deleted }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: 'Node still has containers (code: has_containers)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sites/{siteId}/nodes/{id}/storages:
    get:
      operationId: list_node_storages
      tags: [Nodes]
      summary: List Proxmox CT template storages (empty array when the node has no API credentials or the query fails)
      parameters:
        - { in: path, name: siteId, required: true, schema: { type: integer } }
        - { in: path, name: id, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Array of Proxmox CT template storages
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { type: object, properties: { name: { type: string }, total: { type: integer }, available: { type: integer } } } } } }
        '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sites/{siteId}/nodes/{id}/stats:
    get:
      operationId: get_node_stats
      tags: [Nodes]
      summary: Live hardware utilization (CPU/memory/swap/rootfs, uptime) plus per-datastore usage
      parameters:
        - { in: path, name: siteId, required: true, schema: { type: integer } }
        - { in: path, name: id, required: true, schema: { type: integer } }
      responses:
        '200':
          description: >-
            Live hardware utilization. `available` is false when the node has no
            API credentials or the hypervisor is unreachable.
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object } } }
        '404': { description: 'Node not found (code: not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /jobs/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: integer } }]
    get:
      operationId: get_job
      tags: [Jobs]
      summary: Job metadata (creator or admin; others receive 404)
      responses:
        '200':
          description: Job metadata
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Job' } } }
        '404': { $ref: '#/components/responses/NotFound' }
  /jobs/{id}/status:
    get:
      operationId: get_job_status
      tags: [Jobs]
      parameters:
        - { in: path, name: id, required: true, schema: { type: integer } }
        - { in: query, name: offset, schema: { type: integer, minimum: 0, default: 0 } }
        - { in: query, name: limit, schema: { type: integer, maximum: 1000, default: 1000 } }
      responses:
        '200':
          description: Status rows
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/JobStatusRow' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
  /jobs/{id}/stream:
    get:
      operationId: stream_job_output
      tags: [Jobs]
      summary: Server-sent events stream of job output
      parameters:
        - { in: path, name: id, required: true, schema: { type: integer } }
        - { in: query, name: lastId, schema: { type: integer } }
      responses:
        '200':
          description: >-
            SSE stream — `log` events (`{id, output, timestamp}`) and a final
            `status` event (`{status}`) when the job leaves pending/running.
          content: { text/event-stream: {} }
        '404': { $ref: '#/components/responses/NotFound' }

  /agents:
    post:
      operationId: agent_check_in
      tags: [Agents]
      summary: Agent check-in
      description: |
        Site agents POST their system info and per-service status every 30
        seconds. The response carries the site's config snapshot with a strong
        `ETag`; send it back via `If-None-Match` to receive `304 Not Modified`
        when nothing changed. Allowed from localhost without credentials
        (manager bootstrap) or with an admin API key. Exempt from the CSRF
        guard.
      parameters:
        - in: header
          name: If-None-Match
          required: false
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [siteId, hostname]
              properties:
                siteId: { type: integer }
                hostname: { type: string }
                currentTime: { type: integer, description: 'Agent time, epoch seconds UTC' }
                ipv4Address: { type: string, nullable: true }
                services:
                  type: object
                  additionalProperties:
                    type: object
                    properties:
                      state: { type: string, description: 'systemd ActiveState: active, inactive, failed, ...' }
                      lastApply: { type: string, enum: [success, failure, unknown] }
      responses:
        '200':
          description: 'Config snapshot (`{ data: { site, nginx } }`) with `ETag` header'
        '304': { description: Config unchanged since If-None-Match }
        '422': { description: 'Missing siteId/hostname (code: validation_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    get:
      operationId: list_agents
      tags: [Agents]
      summary: Current status of all agents (admin)
      responses:
        '200':
          description: List of agents
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        siteId: { type: integer }
                        siteName: { type: string, nullable: true }
                        hostname: { type: string }
                        ipv4Address: { type: string, nullable: true }
                        services: { type: object, nullable: true }
                        lastCheckinAt: { type: string, format: date-time, nullable: true }
                        secondsSinceCheckin: { type: integer, nullable: true, description: Computed server-side at response time }
        '403': { $ref: '#/components/responses/Forbidden' }

  /notifications:
    post:
      tags: [Notifications]
      operationId: ingest_notification
      summary: Ingest a node-side event (admin API key)
      description: |
        Inbound webhook for node-side tools (e.g. lxc-oomd) to report events.
        Requires an admin API key (Bearer). The event is persisted and surfaced
        per owner in the UI notification bell. When `owner` is omitted it is
        resolved best-effort from `node` + `ctid` via the Containers table; an
        unresolved owner is stored as null and appears in no user's feed. `ts`
        is epoch seconds and is recorded as `eventAt`. See
        docs/notification-webhook.md for the full contract.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source, severity, message]
              properties:
                source: { type: string, example: lxc-oomd }
                severity: { type: string, enum: [info, warning, critical] }
                node: { type: string, nullable: true, example: opensource-phxdc-pve1 }
                ctid:
                  nullable: true
                  oneOf: [{ type: integer }, { type: string }]
                  example: 392
                owner: { type: string, nullable: true, example: mbachelder }
                action: { type: string, nullable: true, example: freeze }
                message: { type: string, example: 'CT 392 frozen: memory PSI full avg10=83 for 45s' }
                evidence: { type: object, additionalProperties: true, nullable: true }
                ts: { type: integer, description: Epoch seconds when the event occurred, example: 1771234560 }
      responses:
        '201':
          description: Created — the persisted notification
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Notification' }
        '400': { description: Invalid payload, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '401': { description: Missing/invalid credentials, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: Non-admin credentials, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    get:
      tags: [Notifications]
      operationId: list_notifications
      summary: List the current user's notifications
      description: |
        Owner-scoped feed for the notification bell. Returned unacknowledged
        first, then newest first, so the client can derive its unread badge from
        the response.
      parameters:
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: Array of the caller's notifications
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Notification' }
        '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /notifications/all/ack:
    post:
      tags: [Notifications]
      operationId: acknowledge_all_notifications
      summary: Acknowledge all of the caller's notifications
      responses:
        '200':
          description: Number of notifications acknowledged
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [acknowledged]
                    properties:
                      acknowledged: { type: integer, description: Count of notifications marked read }
        '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /notifications/{id}/ack:
    parameters: [{ in: path, name: id, required: true, schema: { type: integer } }]
    post:
      tags: [Notifications]
      operationId: acknowledge_notification
      summary: Acknowledge one notification the caller owns
      responses:
        '200':
          description: The updated notification
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Notification' }
        '401': { description: Authentication required, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Not found or not owned by the caller, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /services/{id}/last-access:
    post:
      operationId: record_service_access
      tags: [Agents]
      summary: Record service access (proxy accounting)
      description: |
        Called by the site proxy (nginx accounting module) on the first
        request/connection to a service after 10+ minutes of none. Sets the
        service's lastAccessedAt to the current server time. Allowed from
        localhost without credentials (manager bootstrap) or with an admin
        API key. Exempt from the CSRF guard for Bearer/localhost callers.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      responses:
        '204': { description: Access recorded }
        '400': { description: 'Non-numeric id (code: invalid_request)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '401': { description: 'Missing/invalid credentials from a non-localhost caller', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /external-domains:
    get:
      operationId: list_external_domains
      tags: [External Domains]
      summary: List external domains (admin)
      responses:
        '200':
          description: List
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/ExternalDomain' } } } }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: create_external_domain
      tags: [External Domains]
      summary: Create an external domain (admin)
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExternalDomainInput' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/ExternalDomain' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
  /external-domains/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: integer } }]
    get:
      operationId: get_external_domain
      tags: [External Domains]
      responses:
        '200':
          description: Item
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/ExternalDomain' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: update_external_domain
      tags: [External Domains]
      summary: Update an external domain (admin). Full update — omitted optional fields are cleared (except `cloudflareApiKey`, which is kept when blank/omitted).
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExternalDomainInput' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/ExternalDomain' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: delete_external_domain
      tags: [External Domains]
      summary: Delete an external domain (admin)
      responses:
        '204': { description: Deleted }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /users:
    get:
      operationId: list_users
      tags: [Users]
      summary: List users (admin)
      responses:
        '200':
          description: List
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/User' } } } }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: create_user
      tags: [Users]
      summary: Create a user (admin)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/UserInput'
                - required: [uid, givenName, sn, mail, userPassword]
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/User' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /users/invite:
    post:
      operationId: invite_user
      tags: [Users]
      summary: Send an email invitation (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties: { email: { type: string, format: email } }
      responses:
        '200':
          description: Invitation sent
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { email: { type: string }, message: { type: string } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { description: 'smtp_not_configured or duplicate_email', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '502': { description: 'Email delivery failed (code: email_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /users/email-all:
    post:
      operationId: email_all_users
      tags: [Users]
      summary: Broadcast an email to every user with an address (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, message]
              properties:
                subject: { type: string }
                message: { type: string }
      responses:
        '200':
          description: Broadcast result
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { sent: { type: integer }, failed: { type: integer }, recipients: { type: integer } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { description: 'smtp_not_configured or no_recipients', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /users/{uidNumber}:
    parameters: [{ in: path, name: uidNumber, required: true, schema: { type: integer } }]
    get:
      operationId: get_user
      tags: [Users]
      responses:
        '200':
          description: User
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/User' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: update_user
      tags: [Users]
      summary: Update a user (admin). Partial — omitted/blank fields keep their current values.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UserInput' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/User' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: delete_user
      tags: [Users]
      summary: Delete a user (admin)
      responses:
        '204': { description: Deleted }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /groups:
    get:
      operationId: list_groups
      tags: [Groups]
      summary: List groups (admin)
      responses:
        '200':
          description: List
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Group' } } } }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: create_group
      tags: [Groups]
      summary: Create a group (admin)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [gidNumber, cn]
              properties:
                gidNumber: { type: integer }
                cn: { type: string }
                isAdmin: { type: boolean }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Group' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
  /groups/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: integer }, description: gidNumber }]
    get:
      operationId: get_group
      tags: [Groups]
      responses:
        '200':
          description: Group
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Group' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: update_group
      tags: [Groups]
      summary: Update a group (admin)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                cn: { type: string }
                isAdmin: { type: boolean }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Group' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: delete_group
      tags: [Groups]
      summary: Delete a group (admin)
      responses:
        '204': { description: Deleted }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /apikeys:
    get:
      operationId: list_api_keys
      tags: [API Keys]
      summary: List the current user's API keys
      responses:
        '200':
          description: List of current user's keys
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/ApiKey' } } } }
    post:
      operationId: create_api_key
      tags: [API Keys]
      summary: Mint a new API key (plaintext returned ONCE)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                description: { type: string, maxLength: 255, nullable: true }
      responses:
        '201':
          description: Created with plaintext `key`
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/ApiKey'
                      - type: object
                        properties:
                          key: { type: string, description: Plaintext API key — this is the only time it is returned }
                          warning: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
  /apikeys/{id}:
    parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
    get:
      operationId: get_api_key
      tags: [API Keys]
      responses:
        '200':
          description: Key metadata
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/ApiKey' } } }
        '400': { description: 'Malformed id — must be a UUID (code: invalid_request)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: delete_api_key
      tags: [API Keys]
      responses:
        '204': { description: Revoked }
        '400': { description: 'Malformed id — must be a UUID (code: invalid_request)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /settings:
    get:
      operationId: get_settings
      tags: [Settings]
      summary: Read system settings (admin)
      responses:
        '200':
          description: System settings
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/Settings' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
    put:
      operationId: update_settings
      tags: [Settings]
      summary: Save system settings (admin). Full update — omitted fields are cleared.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Settings' }
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { saved: { type: boolean } } } } }
        '403': { $ref: '#/components/responses/Forbidden' }

  /resource-requests:
    get:
      operationId: list_resource_requests
      tags: [Resource Requests]
      summary: List resource requests (admins see all; users see only their own)
      parameters:
        - in: query
          name: status
          schema: { type: string, enum: [pending, approved, denied, closed] }
          description: '`closed` matches approved + denied'
        - { in: query, name: siteId, schema: { type: integer } }
      responses:
        '200':
          description: Array of resource requests
          content:
            application/json:
              schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/ResourceRequest' } } } }
    post:
      operationId: create_resource_request
      tags: [Resource Requests]
      summary: >-
        Request a resource adjustment for a container. Auto-approved (and
        applied to matching provisioned containers) when the caller is an admin
        or the value is at or below the default (memory 4096 MB, swap 0 MB,
        cpus 4, rootfs 50 GB); otherwise left pending for admin review.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [siteId, hostname, resourceType, value]
              properties:
                siteId: { type: integer }
                hostname: { type: string }
                resourceType: { type: string, enum: [memory, swap, cpus, rootfs] }
                value: { type: integer, minimum: 0, description: 'memory/swap in MB, rootfs in GB, cpus as a count' }
                comment: { type: string }
      responses:
        '201':
          description: Request created (`status` reflects any auto-approval)
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/ResourceRequest' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /resource-requests/count:
    get:
      operationId: count_pending_resource_requests
      tags: [Resource Requests]
      summary: Count pending resource requests (for badge display)
      responses:
        '200':
          description: Pending count
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { count: { type: integer } } } } }
  /resource-requests/effective/{siteId}/{hostname}/{username}:
    get:
      operationId: get_effective_resources
      tags: [Resource Requests]
      summary: Effective approved resources for a container identity (defaults merged with the most recent approval per type)
      parameters:
        - { in: path, name: siteId, required: true, schema: { type: integer } }
        - { in: path, name: hostname, required: true, schema: { type: string } }
        - { in: path, name: username, required: true, schema: { type: string } }
      responses:
        '200':
          description: Effective resource values
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { memory: { type: integer }, swap: { type: integer }, cpus: { type: integer }, rootfs: { type: integer } } } } }
        '403': { description: 'forbidden — non-admins may only query their own username', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /resource-requests/{id}/approve:
    put:
      operationId: approve_resource_request
      tags: [Resource Requests]
      summary: Approve a pending request and apply it to matching provisioned containers (admin)
      parameters: [{ in: path, name: id, required: true, schema: { type: integer } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                adminComment: { type: string }
      responses:
        '200':
          description: Approved
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/ResourceRequest' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: 'Request already reviewed (code: already_reviewed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { description: 'No provisioned container matches the request (code: container_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /resource-requests/{id}/deny:
    put:
      operationId: deny_resource_request
      tags: [Resource Requests]
      summary: Deny a pending request (admin)
      parameters: [{ in: path, name: id, required: true, schema: { type: integer } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                adminComment: { type: string }
      responses:
        '200':
          description: Denied
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: '#/components/schemas/ResourceRequest' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: 'Request already reviewed (code: already_reviewed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
