openapi: 3.0.3
info:
  title: NX Panel API
  description: >
    Control-plane REST API for the NX Panel. Admin endpoints under `/api`
    accept either a session cookie (obtained from `POST /api/login`) or a
    third-party API token as `Authorization: Bearer` (see `POST /api/tokens`).
    This document is the source of truth for the frontend and any third-party
    integration.


    **Versioning.** Every path below is served twice: at the path shown, and
    under `/api/v1/…` (e.g. `/api/v1/users`). Build third-party integrations
    against the **`/api/v1` prefix** — it is the frozen public contract. The
    unversioned prefix is the panel SPA\u2019s own surface and its shapes may change
    with the frontend. The two differ in exactly one way: list routes answer
    with a pagination envelope under `/api/v1` and with a bare array under
    `/api` (see **Listing** below). Everything else — auth, scopes, status
    codes, single-object shapes — is identical.


    **Listing.** Every list route accepts `limit`, `offset`, `q` (free-text
    search over that resource\u2019s text columns) and `updated_since` (unix
    seconds against `updated_at`, for incremental sync; ignored by the few
    resources with no such column). `X-Total-Count` carries the unpaginated
    total on both prefixes. Under `/api/v1` the body is
    `{items, total, limit, offset}` and the limit defaults to 100 (max 1000);
    under `/api` the body stays a bare array and is unlimited unless `limit`
    is given. Some resources take further filters and a `sort` / `order`
    pair; they are listed on the route.


    **Idempotency.** Any mutating request may carry an `Idempotency-Key`
    header. A repeat of the same key by the same caller replays the first
    successful response byte for byte, marked `Idempotent-Replay: true`,
    instead of running the handler again — so a create that timed out can be
    safely retried. Stored for 24 hours.


    While the first request under a key is still running, a second one
    carrying it does not run: it is answered `409` with `inProgress: true` and
    `Retry-After: 1`, and its retry must carry the **same** key — it then gets
    the stored response. This is what makes a payment callback retried during
    the create it is retrying make one account. A request that fails frees its
    key at once; one that never finishes (the process killed mid-way) frees it
    after ten minutes.


    A key is bound to the whole request, not just the route: the method, the
    path, a digest of the **query string** and a hash of the body. Reusing one
    across any of those answers `409` — which includes two different selections
    of the same bulk route (`?group=trial` and `?group=gold` are one path and,
    since the query-string selector sends no body, one body hash), so give each
    selection its own key.


    Only 2xx outcomes are stored, and not even all of those: a response that
    carries its own `error` — a bulk operation or a batch create that stopped
    part way — is deliberately not stored, so retrying the same key finishes the
    job instead of replaying a partial outcome.


    A third-party token is limited three ways at once, all checked per request:
    it acts as the operator it is bound to (so a reseller's token sees only
    that reseller's users), it carries a role that can never outrank that
    operator's, and it carries a set of scopes narrowing which routes it may
    reach inside that role — `GET /api/scopes` lists the vocabulary, and
    `GET /api/me` reports what the calling token actually holds. Token requests
    are rationed per token per minute and answer with `X-RateLimit-Limit`,
    `X-RateLimit-Remaining` and `X-RateLimit-Reset`; a throttled request gets
    `429` with `Retry-After`. Routes acting on the caller's own account
    (`PUT /api/me/password`, `PUT /api/me/dashboard-tiles`) or on the process
    (`POST /api/restart`) are closed to tokens entirely.
  version: "1.0.0"
servers:
  - url: /
    description: Same-origin (the Panel serves this spec and the SPA).
tags:
  - name: auth
  - name: admins
  - name: users
  - name: plans
  - name: groups
  - name: inbounds
  - name: certificates
  - name: templates
  - name: nodes
  - name: tunnels
  - name: settings
  - name: webhooks
  - name: services
  - name: addons
  - name: install
  - name: subscription
  - name: tools
  - name: meta
  - name: setup

security:
  - cookieAuth: []
  - bearerAuth: []

paths:
  /api/openapi.json:
    get:
      tags: [meta]
      summary: This OpenAPI document (JSON)
      security: []
      responses:
        "200":
          description: OpenAPI 3 document
          content:
            application/json:
              schema: { type: object }
  /api/openapi.yaml:
    get:
      tags: [meta]
      summary: This OpenAPI document (YAML)
      security: []
      responses:
        "200":
          description: OpenAPI 3 document
          content:
            application/yaml:
              schema: { type: string }

  /api/setup:
    get:
      tags: [setup]
      summary: Whether the first-run setup wizard should run
      description: >
        Public by necessity: on a panel with no admin account there is nobody to
        authenticate, and this is how the SPA learns to show the wizard instead
        of the login page. A configured panel answers `required: false` and
        nothing else.


        While setup is pending the panel answers 404 to every route, this one
        included, unless the request carries the setup token in `t` — the link
        the installer prints. A configured panel no longer gates it, and then
        `t` is ignored.
      security: []
      parameters:
        - name: t
          in: query
          required: false
          schema: { type: string }
          description: >
            The one-time setup token (`nx-panel setup-token` reprints it).
            Required while setup is pending; without it this route answers 404
            like every other.
      responses:
        "200":
          description: Setup state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetupState" }
        "404":
          description: >
            Setup is pending and the request carried no valid `t` token — the
            same answer every other route gives until the panel is configured.
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [setup]
      summary: Complete the first-run setup
      description: >
        Creates the main admin, records every mandatory setting and issues the
        panel's own certificate — all in one transaction, so a rejected field
        leaves the panel untouched and still in setup. Authorised by the
        one-time token the installer printed (`nx-panel setup-token`
        reprints it); the token is retired when setup completes, and the route
        answers 409 from then on. The web settings are read at startup, so the
        panel restarts itself and the response says where it will answer.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetupRequest" }
      responses:
        "200":
          description: Setup complete; the panel is restarting
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  url:
                    type: string
                    description: Where the panel answers after the restart
                  restart: { type: boolean }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        # 409 means only "someone else finished setup first" — retrying will not
        # help. A write that failed for any other reason is a 500, so a client
        # can tell "already done" from "the panel is broken" and retry the one
        # that is worth retrying.
        "500": { $ref: "#/components/responses/Error" }
  /api/login:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Log in and set the session cookie
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username: { type: string }
                password: { type: string }
                remember: { type: boolean, description: "Ask for the long-lived session (30 days of inactivity instead of 24 hours). Both slide with use." }
      responses:
        "200":
          description: >
            Logged in; `nx_session` cookie set. Sessions survive a panel
            restart; see /api/me/sessions. For an account with two-factor
            authentication on, the password alone earns no session: the body is
            `MfaPending` instead, no cookie is set, and the login finishes at
            `/api/login/mfa` with a code from the authenticator or a recovery code.
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/Me" }
                  - { $ref: "#/components/schemas/MfaPending" }
        "401": { $ref: "#/components/responses/Error" }
  /api/login/mfa:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Finish a two-factor login
      description: >
        The second step for an account with 2FA on: the `pending` token from
        `/api/login` plus a six-digit TOTP code or one of the single-use
        recovery codes. A wrong code counts against the same per-address
        limiter as a wrong password and does not consume the pending token; a
        right one does. The pending token lives five minutes and dies with a
        restart.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pending, code]
              properties:
                pending: { type: string }
                code: { type: string, description: "A TOTP code (spaces ignored) or a recovery code (case and dash ignored)" }
      responses:
        "200":
          description: Logged in; `nx_session` cookie set
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /api/logout:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Log out (clears the session cookie)
      security: []
      responses:
        "200": { description: Logged out }
  /api/me:
    get:
      tags: [auth]
      summary: Current admin session
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Error" }
  /api/me/password:
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Change the current admin's password (any role)
      description: Verifies the current password, then revokes the admin's other sessions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currentPassword, newPassword]
              properties:
                currentPassword: { type: string }
                newPassword: { type: string }
      responses:
        "200": { description: Changed }
        "401": { $ref: "#/components/responses/Error" }
  /api/me/2fa:
    get:
      tags: [auth]
      summary: The caller's two-factor state
      description: Whether TOTP is on, since when, and how many recovery codes remain. Never the seed. API tokens have no second factor and are refused (400).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  enabledAt: { type: integer, format: int64 }
                  recoveryCodesLeft: { type: integer }
        "400": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Turn two-factor authentication off
      description: Takes a TOTP or recovery code — whoever finds a session open must not be able to remove the second factor. Clears the seed and the recovery codes and stops the confirmation prompts on every session of the account.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [code], properties: { code: { type: string } } }
      responses:
        "200": { description: Off }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
  /api/me/2fa/totp:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Start TOTP enrolment
      description: >
        A fresh base32 secret and the `otpauth://` URI an authenticator app
        enrols from. The secret is held on the calling session only — nothing
        is written to the account until `/api/me/2fa/totp/confirm` sees a code
        from it — so an abandoned enrolment leaves nothing behind, and a panel
        restart forgets it. Refused (400) while 2FA is already on: changing the
        seed is disable-then-enrol, and disabling takes a code.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret: { type: string, description: "Base32, no padding; for typing into an app by hand" }
                  uri: { type: string, description: "otpauth://totp/NX:<username>?secret=…&issuer=NX — render as a QR code" }
        "400": { $ref: "#/components/responses/Error" }
  /api/me/2fa/totp/confirm:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Finish TOTP enrolment
      description: A code from the pending secret turns 2FA on and answers the ten single-use recovery codes — the only time they are shown. Every session of the account starts asking for confirmation on sensitive routes from here on.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [code], properties: { code: { type: string } } }
      responses:
        "200":
          description: On
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  recoveryCodes: { type: array, items: { type: string }, description: "Shown once; store them" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
  /api/me/2fa/recovery:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Replace the recovery codes
      description: A new set of ten, invalidating the old ones. Takes a TOTP code.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [code], properties: { code: { type: string } } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { recoveryCodes: { type: array, items: { type: string } } } }
        "401": { $ref: "#/components/responses/Error" }
  /api/me/confirm:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Re-present the second factor on this session
      description: >
        Sensitive routes — admin management, API tokens, licence install,
        restore, restart, and the panel-wide settings in `PanelSettingKeys` —
        answer **428** with `{"confirm": "totp"}` to a session whose second
        factor is older than ten minutes. A code here re-opens the window and
        the client retries. Sessions without 2FA, and API tokens, are never
        asked.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [code], properties: { code: { type: string } } }
      responses:
        "200":
          description: Confirmed
          content:
            application/json:
              schema: { type: object, properties: { confirmedAt: { type: integer, format: int64 }, until: { type: integer, format: int64 } } }
        "401": { $ref: "#/components/responses/Error" }
  /api/me/sessions:
    get:
      tags: [auth]
      summary: The caller's live login sessions
      description: >
        Every login of the calling account that has not expired, most recently
        active first, with the one behind this request flagged `current`. Sessions
        are rows in `admin_sessions` and survive a panel restart. An API token has
        no login sessions and is refused (400); the route is closed to tokens in
        the scope table anyway.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/AdminSession" } }
        "400": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: End every other session of the calling account
      description: '"Sign out everywhere else": every session of the caller''s account but the one making the request.'
      responses:
        "200": { description: Revoked }
        "400": { $ref: "#/components/responses/Error" }
  /api/me/sessions/{id}:
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { name: id, in: path, required: true, schema: { type: integer } }
      tags: [auth]
      summary: End one of the caller's sessions
      description: By session id from the listing. Ending the current one is a logout. A session that is not the caller's answers 404, the same as one that does not exist.
      responses:
        "200": { description: Revoked }
        "404": { $ref: "#/components/responses/Error" }
  /api/me/dashboard-tiles:
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [auth]
      summary: Save the calling admin's dashboard tile selection
      description: >
        The pinned tile keys in order, comma-separated. An empty string means
        nothing is pinned, which is distinct from the value being null (never
        chosen — the UI then shows its defaults). Read it back from `/api/me`.
        Always the caller's own row; API tokens have no account and are refused.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              properties:
                value: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  value: { type: string }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }

  /api/roles:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [admins]
      summary: List roles (sudo only)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Role" } } } } }
        "403": { $ref: "#/components/responses/Error" }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Create a role (sudo only)
      description: >
        Refused with 403 when the role would grant a permission the caller does
        not hold itself, or is based on a tier above the caller's own.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Role" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Role" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
  /api/roles/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Update a role (sudo only)
      description: >
        A built-in role answers 400. A rename carries the accounts holding the
        role with it, and any change ends every session on it: a session carries
        the scope set it was issued with. A `base` moving onto or off `resale`
        switches the template restriction of every holder with `templatesOnly`:
        each one's users are fitted to it in the same transaction, as saving
        that account would fit them (one `user.bulk`, `op` `templates`, per
        account whose users moved, and the templated nodes pushed once). A move
        onto `sudo` is a promotion: the users keep the templates they are on.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Role" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Role" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Delete a role (sudo only)
      description: >
        Refused with 400 when the role is held by any account, naming them, and
        for a built-in role.
      responses:
        "200": { description: Deleted }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/admins:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [admins]
      summary: List admins (sudo only)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Admin" } } } } }
        "403": { $ref: "#/components/responses/Error" }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Create an admin (sudo only)
      description: A `subDomain` is written into the ingress in the same transaction; on an ingress the old settings cannot describe it is a route of the Ingress page's, and naming one answers 409. 500 when the ingress cannot be read.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username: { type: string }
                password: { type: string }
                role: { $ref: "#/components/schemas/AdminRole" }
                volume: { type: integer, format: int64, description: "Reseller allowance in bytes, 0 = unlimited" }
                period: { $ref: "#/components/schemas/ResalePeriod" }
                periodDay: { type: integer }
                expiry: { type: integer, format: int64 }
                userLimit: { type: integer }
                templatesOnly: { type: boolean }
                templateIds: { type: array, items: { type: integer } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Admin" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/admins/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Update an admin's username/role (sudo only)
      description: The last sudo admin cannot be demoted. A role change revokes the admin's sessions, and needs the caller to hold every scope of both the new role and the one the account leaves; any edit of an account on the caller's own tier or above needs the caller to hold every scope of its role (403 otherwise). Every save fits the users the account owns to the template grant it leaves it with (`templatesOnly`, `templateIds`, or a role moving it on or off the resale tier) in the same transaction, which also repairs users that drifted outside it; when any user moved it pushes every templated node live and raises one `user.bulk` with `op` `templates`. `narrowed` in the answer counts the users it changed. Changing `subDomain` on an ingress the old settings cannot describe answers 409 (the domain is then a route of the Ingress page's); 500 when the ingress cannot be read.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, role]
              properties:
                username: { type: string }
                role: { $ref: "#/components/schemas/AdminRole" }
                volume: { type: integer, format: int64, description: "Reseller allowance in bytes, 0 = unlimited" }
                period: { $ref: "#/components/schemas/ResalePeriod" }
                periodDay: { type: integer }
                expiry: { type: integer, format: int64 }
                userLimit: { type: integer }
                templatesOnly: { type: boolean }
                templateIds: { type: array, items: { type: integer } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Admin" }
                  - type: object
                    properties:
                      narrowed: { type: integer, description: "Users this save moved onto the reseller's template grant" }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Delete an admin (sudo only)
      description: Self-deletion and deleting the last sudo admin are rejected, and so is deleting an account whose scopes the caller does not cover (403). Its subscription domain leaves the ingress with it. An account one of whose API tokens an `api` route admits (or whose addon slug such a route admits, with no other token carrying it), in the ingress or in the table a change waiting for its confirmation would put back, is refused with 409 naming the route; so is an account a route is bound to on an ingress the old settings cannot describe, or in the table a change waiting for its confirmation would put back, is refused with 409 until the route is unbound on the Ingress page or the change settles. 500 when the ingress cannot be read.
      responses:
        "200": { description: Deleted }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/admins/{id}/owner:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Hand the panel to another main admin (the owner's login only; an API token is 403)
      description: >
        Transfers ownership: the flag moves, so there is never a second owner.
        The caller must be the current owner (403 otherwise, even for another
        main admin) and the target must already be on a main-admin role (400
        otherwise) — promoting it here would decide somebody's role for them as
        a side effect. The previous owner keeps the role it had and becomes an
        ordinary main admin.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Admin" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/admins/{id}/password:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Reset an admin's password (sudo only)
      description: Revokes the target admin's sessions (except the caller's own login on self-reset; an API token resetting its own account's password counts as another caller). Another account is reset only by a caller holding every scope it holds (403 otherwise — a narrowed sudo role with admins:write cannot take the owner's account). Resetting another account's password is the recovery path and also removes what reaches that account besides its password — its single sign-on links, its paired Telegram chats and its confirmed email addresses.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string }
      responses:
        "200": { description: Changed }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }

  /api/admins/{id}/sessions:
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { name: id, in: path, required: true, schema: { type: integer } }
      tags: [admins]
      summary: End every login session of an admin (sudo; another account only when the caller holds every scope of its role, 403 otherwise)
      description: >
        The account keeps working and its next login starts a fresh session;
        this ends the ones that exist — a lost laptop, a departed operator. The
        caller's own session is kept when the id is their own account.
      responses:
        "200": { description: Revoked }
        "404": { $ref: "#/components/responses/Error" }
  /api/users:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
        - { name: status, in: query, schema: { type: string }, description: "Comma-separated subset of `active`, `disabled`, `pending`, `expired`, `limited` (any of them). A user has exactly one status, decided in that order: disabled beats pending (a plan measured from the first connection that has not had one) beats expired beats limited (volume used up)." }
        - { name: online, in: query, schema: { type: boolean }, description: "`true`: traffic within the last 90 s; `false`: none" }
        - { name: group, in: query, schema: { type: string }, description: "Exact group label" }
        - { name: admin_id, in: query, schema: { type: string, pattern: "^(me|[0-9]+)$" }, description: "Owner: an operator's or reseller's admin id, `0` for users owned by the panel (the main admins' and unbound tokens'), or `me` for the caller's own — its id, or `0` for a main admin. Ignored for a reseller session, which only ever sees its own." }
        - { name: template_id, in: query, schema: { type: integer, minimum: 1 }, description: "Users the template provisions: its explicit members plus every all-templates user" }
        - { name: node_id, in: query, schema: { type: integer, minimum: 1 }, description: "Users the node serves, through its template; 404 for an unknown node" }
        - { name: inbound_id, in: query, schema: { type: integer, minimum: 1 }, description: "Users served on the inbound: through a template that carries it, and allowed by their own `configFilter`" }
        - { name: endpoint_id, in: query, schema: { type: integer, minimum: 1 }, description: "Users served on the endpoint: through a template that carries it, and allowed by their own `configFilter`" }
        - { name: protocol, in: query, schema: { type: string }, description: "Users served on at least one inbound of this type (`vless`, `hysteria2`, …), their `configFilter` included" }
        - { name: expires_after, in: query, schema: { type: integer, format: int64, minimum: 0 }, description: "Unix seconds, inclusive lower bound on `expiry`; users that never expire are excluded" }
        - { name: expires_before, in: query, schema: { type: integer, format: int64, minimum: 0 }, description: "Unix seconds, inclusive upper bound on `expiry`; users that never expire are excluded" }
        - { name: expiry, in: query, schema: { type: string, enum: [never] }, description: "`never`: only users with no expiry" }
        - { name: pending, in: query, schema: { type: boolean }, description: "Whether the plan has started. A plan measured from the first connection carries a *provisional* expiry until somebody connects, so `pending=false` is what makes an expiry bound safe to act on. Deliberately not the `pending` status, which additionally requires the account to be enabled." }
        - { name: used_min, in: query, schema: { type: integer, format: int64, minimum: 0 }, description: "Inclusive lower bound on `up + down`, in bytes" }
        - { name: used_max, in: query, schema: { type: integer, format: int64, minimum: 0 }, description: "Inclusive upper bound on `up + down`, in bytes" }
        - { name: auto_reset, in: query, schema: { type: boolean } }
        - { name: sub_fetched, in: query, schema: { type: boolean }, description: "Whether the subscription *body* has ever been handed to a client (`subFetchedAt > 0`). `false` is the account nobody has pointed a client at yet — distinct from `subSeenAt`, which the page and its polling also set." }
        - { name: contact_telegram_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.telegram_id`. `contact_email`, `contact_phone`, `contact_bale_id`, `contact_soroush_id` and `contact_rubika_id` are the same for the other standard keys; the free-text `q` matches all six as substrings." }
        - { name: contact_email, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.email`." }
        - { name: contact_phone, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.phone`. Matched as stored, so a number kept as `+98 912 …` is not found by `0912…`." }
        - { name: contact_bale_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.bale_id`, the chat id a Bale bot links." }
        - { name: contact_soroush_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.soroush_id`, the chat id a Soroush Plus bot links." }
        - { name: contact_rubika_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.rubika_id`, the chat id a Rubika bot links." }
        - { name: sort, in: query, schema: { type: string, enum: [id, name, usage, remaining, expiry, online_at, sub_fetched_at, sub_seen_at, created_at, updated_at], default: id }, description: "`usage` is `up + down`; `remaining` is `volume - up - down` with an unlimited volume counting as infinite; `expiry`, `online_at`, `sub_fetched_at` and `sub_seen_at` put users with none last in either direction. The id is always the final tiebreaker, so a sorted walk pages stably." }
        - { name: order, in: query, schema: { type: string, enum: [asc, desc], default: asc } }
      tags: [users]
      summary: List users
      description: >
        A reseller sees only the users it owns; operators see every user.
        `q` matches the name, group, description, remark and subscription id.
        Every filter ANDs with the others and with `q` / `updated_since`, and
        `X-Total-Count` is the filtered total.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/User" } } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [users]
      summary: Create a user
      description: >
        A reseller always creates users for itself (adminId is forced to its own
        account) and is refused once its user limit is reached or its account has
        expired.


        Without `adminId` the new account is the caller's own: an operator's
        (or a token bound to one) goes to that operator, a main admin's — and an
        unbound token's — to the panel (`0`). Every operator still sees every
        user; the owner is what the users page's "my users" filter reads.


        Only a main admin, or a token bound to nobody, may create for somebody
        else: an operator's `adminId` is replaced by its own id, as a
        reseller's is. When one of those names `adminId`, it hands the new
        account to that account: an operator
        takes it as it is, `0` is the panel's own, and a main admin or nobody
        is `400`. Handed to a reseller, the account is held to the reseller's
        own rules exactly as if it had made it: the reseller's user cap and
        expiry apply (`403`, or `402` when its role's cap is the one that
        binds), and a `plansOnly` reseller's `planId` rule below. The same holds
        when an edit moves an existing account to another owner; an edit that
        leaves `adminId` out — a `PUT` included — keeps the owner it had, and an
        operator's edit keeps it whatever it sends.


        Sending `planId` applies that plan: every limit it carries (volume, the
        length, the speed and IP caps, the periodic reset, the template
        membership and the group label) is written over whatever else the body
        contained, and the username is wrapped in the plan's prefix and suffix.
        An unknown id is a `400`. The field is honoured on create only — see
        `PUT /api/users/{id}`.


        For a `plansOnly` reseller `planId` is **required** and must be one of
        its `planIds`; both failures answer `403`.


        Credentials: each of `config.uuid`, `config.password`, `config.ss16` and
        `config.ss32` the body leaves out or empty is generated for this
        account (a missing or `null` `config` counts as all four left out).
        Ones the body carries are kept exactly, so an import keeps the links its
        customers already have. A body that carries none of the four also gets
        `config.flow: xtls-rprx-vision` unless it names a flow — the set the
        panel's own form and `POST /api/users/generate` mint; the node is given
        the flow only where vision can travel.
      requestBody: { $ref: "#/components/requestBodies/User" }
      responses:
        "200": { $ref: "#/components/responses/User" }
        "400": { $ref: "#/components/responses/Error" }
        "402": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { description: "The Idempotency-Key was used for a different request, or its first request is still running (`inProgress: true`, `Retry-After`).", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /api/users/summary:
    get:
      tags: [users]
      summary: Count users by status, plus the group labels in use
      description: >
        The readout above the users table: how many users the caller may see,
        how many are online (traffic within 90 s), one counter per status —
        the same test each `status` filter applies, so the two agree — and the
        distinct non-empty group labels, sorted. A reseller's numbers cover
        only its own users. `admin_id` narrows the counters (not the group
        labels) the way it narrows the list, so the strip agrees with a table
        filtered to one owner — the users page's default is `me`.
      parameters:
        - { name: admin_id, in: query, schema: { type: string, pattern: "^(me|[0-9]+)$" }, description: "As on `GET /api/users`: an id, `0`, or `me`. Ignored for a reseller." }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer, format: int64 }
                  online: { type: integer, format: int64 }
                  active: { type: integer, format: int64 }
                  pending: { type: integer, format: int64 }
                  limited: { type: integer, format: int64 }
                  expired: { type: integer, format: int64 }
                  disabled: { type: integer, format: int64 }
                  neverFetched: { type: integer, format: int64, description: "Accounts whose subscription body has never been handed over; a counter, not a status" }
                  unlicensed: { type: integer, format: int64, description: "Rows past the licence cutoff, within what the caller may see: listed and deletable, served on no node, refusing every other write with 402. 0 on a licensed install." }
                  groups: { type: array, items: { type: string } }
  /api/users/config-filter/options:
    get:
      tags: [users]
      summary: The values a config filter's dropdowns offer
      description: >
        Per section (`inbounds`, `endpoints`) and field, every value the field
        takes: each inbound's and endpoint's tag and type, each inbound's
        transport, and the three `security` values. Across the rows some
        template selects that carry accounts (a client endpoint or a
        static-key OpenVPN server is no row a rule can match), or for a
        reseller limited to chosen templates across the rows of those
        templates — a reseller cannot read the pool lists itself.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [inbounds, endpoints]
                properties:
                  inbounds: { type: object, additionalProperties: { type: array, items: { type: string } } }
                  endpoints: { type: object, additionalProperties: { type: array, items: { type: string } } }
  /api/users/config-filter/preview:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [users]
      summary: Which of an account's configs a filter allows
      description: >
        The user-carrying inbounds and endpoints of the templates named — for
        `allTemplates`, of every template, never a pool row no template
        selects — fitted to a restricted reseller's grant as a save would fit
        them (a reseller's own, or for any other caller the grant of the owner
        `adminId` names), each with
        whether `configFilter` lets the account on it — judged by the same
        code the node plan and the subscription use. Changes nothing. A filter
        a save would refuse is `400` with the same message.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                allTemplates: { type: boolean }
                templateIds: { type: array, items: { type: integer } }
                adminId: { type: integer, description: "The account's owner as the form holds it, whose template grant the templates are fitted to. Ignored when the caller is a reseller: its own grant applies." }
                configFilter: { allOf: [ { $ref: "#/components/schemas/ConfigFilter" } ], nullable: true }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [inbounds, endpoints]
                properties:
                  inbounds: { type: array, items: { $ref: "#/components/schemas/ConfigFilterPreviewRow" } }
                  endpoints: { type: array, items: { $ref: "#/components/schemas/ConfigFilterPreviewRow" } }
        "400": { $ref: "#/components/responses/Error" }
  /api/users/owners:
    get:
      tags: [users]
      summary: List the accounts a user can be owned by
      description: >
        Every operator and reseller — the accounts that own users — with the
        tier their role resolves to and how many users each owns, sorted by
        username. Main admins are left out: what they create belongs to the
        panel (`adminId` 0). This is what the owner picker and the owner
        filter read, and it exists beside `GET /api/admins` because an
        operator sees every user but may not list admins. Operators only.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: integer }
                    username: { type: string }
                    base: { type: string, enum: [admin, resale] }
                    users: { type: integer, format: int64 }
                    templatesOnly: { type: boolean, description: "A reseller limited to chosen templates: the user form offers only templateIds for its users" }
                    templateIds: { type: array, items: { type: integer }, description: "The templates this owner's users may be on; empty when unrestricted" }
        "403": { $ref: "#/components/responses/Error" }
  /api/users/bulk/{op}:
    post:
      parameters:
        - { name: op, in: path, required: true, schema: { type: string, enum: [enable, disable, reset-traffic, reset-devices, delete, add-days, add-months, add-traffic, limits, owner, config-filter] } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { name: all, in: query, schema: { type: string, enum: ["true"] }, description: "Run on every user the caller owns. Required when no `ids` and no filter are sent, so an omitted parameter can never mean *everything*." }
        - { $ref: "#/components/parameters/Q" }
        - { name: status, in: query, schema: { type: string } }
        - { name: online, in: query, schema: { type: boolean } }
        - { name: group, in: query, schema: { type: string } }
        - { name: admin_id, in: query, schema: { type: string, pattern: "^(me|[0-9]+)$" }, description: "As on `GET /api/users`: an id, `0`, or `me`." }
        - { name: template_id, in: query, schema: { type: integer, minimum: 1 } }
        - { name: node_id, in: query, schema: { type: integer, minimum: 1 } }
        - { name: inbound_id, in: query, schema: { type: integer, minimum: 1 } }
        - { name: endpoint_id, in: query, schema: { type: integer, minimum: 1 } }
        - { name: protocol, in: query, schema: { type: string } }
        - { name: expires_after, in: query, schema: { type: integer, format: int64, minimum: 0 } }
        - { name: expires_before, in: query, schema: { type: integer, format: int64, minimum: 0 } }
        - { name: expiry, in: query, schema: { type: string, enum: [never] } }
        - { name: pending, in: query, schema: { type: boolean } }
        - { name: used_min, in: query, schema: { type: integer, format: int64, minimum: 0 } }
        - { name: used_max, in: query, schema: { type: integer, format: int64, minimum: 0 } }
        - { name: auto_reset, in: query, schema: { type: boolean } }
        - { name: sub_fetched, in: query, schema: { type: boolean }, description: "Whether the subscription *body* has ever been handed to a client (`subFetchedAt > 0`). `false` is the account nobody has pointed a client at yet — distinct from `subSeenAt`, which the page and its polling also set." }
        - { name: contact_telegram_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.telegram_id`. `contact_email`, `contact_phone`, `contact_bale_id`, `contact_soroush_id` and `contact_rubika_id` are the same for the other standard keys; the free-text `q` matches all six as substrings." }
        - { name: contact_email, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.email`." }
        - { name: contact_phone, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.phone`. Matched as stored, so a number kept as `+98 912 …` is not found by `0912…`." }
        - { name: contact_bale_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.bale_id`, the chat id a Bale bot links." }
        - { name: contact_soroush_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.soroush_id`, the chat id a Soroush Plus bot links." }
        - { name: contact_rubika_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.rubika_id`, the chat id a Rubika bot links." }
        - { $ref: "#/components/parameters/UpdatedSince" }
        - { name: limit, in: query, schema: { type: integer, minimum: 0, maximum: 1000 }, description: "Parsed and range-checked (a negative or non-integer value is a 400) but **not applied**: this route acts on the whole selection, and `limit` does not cap it. Narrow the filter, or send `ids`." }
        - { name: offset, in: query, schema: { type: integer, minimum: 0 }, description: "Parsed and range-checked but **not applied**; see `limit`." }
        - { name: sort, in: query, schema: { type: string, enum: [id, name, usage, remaining, expiry, online_at, sub_fetched_at, sub_seen_at, created_at, updated_at] }, description: "Accepted and validated (a bad value is a 400); it does not change which rows the operation acts on." }
        - { name: order, in: query, schema: { type: string, enum: [asc, desc] }, description: "Accepted and validated; see `sort`." }
      tags: [users]
      summary: Run one operation over a whole selection
      description: >
        One request, one statement per batch, and one push per affected node —
        rather than repeating a single-user route, which reconciles the fleet
        once per row.


        The selection arrives one of two ways. Send `{"ids": [...]}` to name the
        rows. Send no body and use the **query parameters of `GET /api/users`**
        — every filter above means exactly what it means there, applied by the
        same code — to act on everyone a filter finds; `X-Total-Count` from that
        same list request is therefore a preview of what this will touch.
        Sending neither is a `400`: `all=true` is the only way to say "every
        user", and it has to be typed.


        A reseller reaches only the accounts it owns (ids naming anything else
        are dropped, not refused) and is denied outright when `usersReadOnly` is
        set. At most 5000 users per call; a wider filter answers `400` rather
        than silently acting on part of it.


        `enable` deliberately skips accounts the quota enforcer would disable
        again within the minute — past their expiry, or over their volume — and
        counts them as `blocked`. `reset-traffic` zeroes the current cycle and
        moves the next periodic reset, leaving lifetime totals alone, exactly as
        the scheduled reset does. `reset-devices` forgets every device the
        selection has registered, freeing the places `deviceLimit` counts;
        `affected` is the number of users that had any, not the number of
        devices dropped, and a device already holding the configs keeps
        connecting — what changes is who may fetch next.


        `add-days`, `add-months`, `add-traffic`, `limits` and `config-filter` rewrite what an account was
        sold, so a `plansOnly` reseller is refused them (`403`) while the rest
        stay open to it.


        `owner` hands the selection to `adminId` — an operator, a reseller, or
        `0` for the panel; a main admin is `400`. Only a main admin, or a token
        bound to nobody, may run it; an operator and a reseller are `403`. Handing it to a reseller is held to that reseller's rules for
        the whole selection at once: an expired account is `403`, and one
        whose user cap cannot take every row not already its own is `402`,
        with nothing moved.


        Bulk operations emit no per-user events: one `user.bulk` event
        carries the summary (op, matched, affected, blocked, failed).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ids: { type: array, items: { type: integer, minimum: 1 }, description: "The users to act on. When absent, the query parameters select them." }
                months: { type: integer, format: int64, description: "`add-months`: whole calendar months to add, signed, at most 120. Renewal as an operator sells it — the same day of the month, clamped to a short month's last day (31 January plus one month is 28 February, not 3 March) and keeping the time of day. It moves the **expiry only**: a plan measured from the first connection is a length in seconds and a calendar month is not a number of seconds, so its `duration` is left alone and `add-days` stays the operation for moving a plan's length. An account with no expiry, or one whose plan has not started (its date is provisional and activation overwrites it), is `blocked`." }
                fromNow: { type: boolean, description: "`add-days` and `add-months`: count a dated account's time from the later of now and its expiry, rather than from the expiry — an account that ran out ten days ago given thirty days has thirty, not twenty. An account whose plan has not started keeps adding to its provisional date. Positive amounts only. For one account, `POST /api/users/renew/{id}` does this and raises `user.renewed`." }
                calendar: { type: string, enum: ["", "jalali"], description: "`add-months`: the calendar the months are counted in. Empty = Gregorian. Named by the request rather than read off the account, because which month was sold is something the operator knows and the row does not." }
                days: { type: integer, format: int64, description: "`add-days`: days to add, signed. A plan measured from the first connection moves both its length and its date; a fixed-date account moves its date; one that never expires is `blocked`. Nothing is ever reduced to 0, which would mean *never expires*." }
                bytes: { type: integer, format: int64, description: "`add-traffic`: bytes to add to the volume cap, signed. An account with no cap is `blocked` — adding to unlimited would take the unlimited away." }
                volume: { type: integer, format: int64, description: "`limits`: byte cap, 0 = unlimited. Absent = unchanged." }
                expiry: { type: integer, format: int64, description: "`limits`: unix seconds. Makes the account fixed-date: the plan length and activation are cleared with it, because while a length is set the panel recomputes the date from the first connection and would throw this away." }
                speedLimit: { type: integer, format: int64, description: "`limits`: bytes/second per direction, 0 = unlimited. Absent = unchanged." }
                ipLimit: { type: integer, description: "`limits`: concurrent addresses, 0 = unlimited. Absent = unchanged." }
                deviceLimit: { type: integer, description: "`limits`: devices that may hold the subscription, 0 = unlimited. Absent = unchanged." }
                autoReset: { type: boolean, description: "`limits`: periodic reset. Switching it **on** requires a cycle shape in the same request (400 otherwise) — `resetDays` >= 1 or a non-empty `resetPeriod`, because a cycle with no shape can never come round and the stored shape of every selected account cannot be seen from here. Switching it on opens the next appointment (nothing else does); switching it off clears it." }
                resetDays: { type: integer, minimum: 0, maximum: 3650, description: "`limits`: the rolling cycle's length in days. Absent = unchanged. **0 clears the whole cycle** — `autoReset` and the pending appointment with it — unless the same request names a calendar `resetPeriod`, where the rolling length is the half being cleared rather than the cycle." }
                resetPeriod: { type: string, enum: ["", "weekly", "monthly", "yearly"], description: "`limits`: put the selection on a calendar cycle, or back on the rolling one. Clearing it to `\"\"` requires `resetDays` in the same request, for the same reason switching `autoReset` on does." }
                resetStartDay: { type: integer, minimum: 0, maximum: 31, description: "`limits`: the calendar cycle's anchor — weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. Only beside `resetPeriod` (400 alone): the period is set as a whole, and an anchor or calendar left out beside it takes the fresh-row value (day 1, month 1, Gregorian) rather than keeping what each row stored." }
                resetStartMonth: { type: integer, minimum: 1, maximum: 12, description: "`limits`: the anchor month, read for the yearly cycle only. Only beside `resetPeriod` (400 alone)." }
                resetCalendar: { type: string, enum: ["", "jalali"], description: "`limits`: the calendar a monthly or yearly boundary is counted in. Empty = Gregorian. Only beside `resetPeriod` (400 alone)." }
                adminId: { type: integer, minimum: 0, description: "`owner`: the account to hand the selection to, 0 for the panel. Required." }
                configFilter: { allOf: [ { $ref: "#/components/schemas/ConfigFilter" } ], nullable: true, description: "`config-filter`: the filter every selected account gets, replacing its own; `null` lifts it, and a filter with no rules is `400` rather than a lift under another name. Required. `affected` counts the accounts whose filter changed." }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  op: { type: string }
                  matched: { type: integer, description: "How many users the selector found" }
                  affected: { type: integer, description: "How many rows actually changed — smaller whenever some were already in the requested state" }
                  blocked: { type: integer, description: "Rows the operation deliberately left alone (see `enable`)" }
                  failed: { type: integer, description: "Rows in batches that never ran, after an error partway through" }
                  appliedThrough: { type: integer, description: "Present only when the run stopped early: the highest user id it got past. The selection is always in ascending id order, so every row at or below this id has been dealt with and a retry should carry only the remainder — which is what makes a half-applied, non-idempotent operation such as `add-days` recoverable. Resuming means re-sending the operation with the remainder in `ids`: the filter selectors have no id-range parameter, so a filter-selected run has to be narrowed to ids before it can be resumed." }
                  error: { type: string, description: "Why the operation stopped early. Accompanied by `failed`, which counts the rows the run never reached. A response carrying this is not stored for `Idempotency-Key` replay, so the same key retries rather than replaying a partial outcome." }
        "400": { $ref: "#/components/responses/Error" }
        "402": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/users/renew/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [users]
      summary: Renew a user
      description: >
        What a sale renews: time, traffic, or a fresh cycle of usage, for one
        account. Time counts **from the later of now and the current
        expiry** — renewed before its date the account keeps what it had
        left, renewed after it starts again today — so a customer who paid
        for thirty days gets thirty. The credentials, the subscription link
        and the usage history are kept.


        `days` or `months` (with `calendar`, as `add-months` counts them),
        not both. An account whose plan has not started (measured from the
        first connection) stays on hold: `days` lengthen the plan and its
        provisional date together, and `months` are `409`, since a calendar
        month is not a length. An account that never expires is `409` for
        time, and one with unlimited traffic is `409` for `traffic` — a
        renewal never takes an unlimited allowance away. `resetUsage` zeroes
        the current cycle's counters (the lifetime totals stay) and moves a
        reset cycle's next date. A plan cannot be named: a sales program maps
        its product to these amounts.


        An account the quota enforcer switched off for its date or its
        traffic is judged again at once and comes back on (`user.enabled`)
        rather than on the next minute's pass; one a person switched off
        stays off. Two renewals of one account at the same moment both
        count. Raises `user.renewed` `{userId, name, adminId, expiry,
        volume}`. A reseller renews its own accounts; a `usersReadOnly` or a
        `plansOnly` one is `403`.


        Why not `/api/users/{id}/renew`: that pattern and `POST
        /api/users/bulk/{op}` would both match `/api/users/bulk/renew`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                days: { type: integer, format: int64, minimum: 1, maximum: 3650 }
                months: { type: integer, format: int64, minimum: 1, maximum: 120 }
                calendar: { type: string, enum: ["", "jalali"], description: "With `months` only. Empty = Gregorian." }
                traffic: { type: integer, format: int64, minimum: 1, description: "Bytes added to the volume cap." }
                resetUsage: { type: boolean }
      responses:
        "200": { $ref: "#/components/responses/User" }
        "400": { $ref: "#/components/responses/Error" }
        "402": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { description: "Nothing of that kind to renew (the account never expires, its traffic is unlimited, or months on a plan that has not started), or the account kept changing while it was being renewed.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /api/users/generate:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [users]
      summary: Create a batch of accounts
      description: >
        The body is an ordinary user create — `planId`, template membership,
        group, the per-user limits — plus how many accounts to make and what to
        call them, so a batch is bought exactly the way one account is.


        `pattern` must carry a counter, a random part, or both. `{n}` numbers
        plainly and `{nnn}` pads to three digits, so a series sorts the way it
        was created; accounts are numbered `start` upward. `{r}` draws six
        random characters and `{r8}` draws eight, from lowercase letters and
        digits with the confusable ones (`0`/`o`, `1`/`l`, `i`) left out — a
        name a customer can read down a phone but a neighbour cannot guess.


        A name already taken is **skipped**, not fatal, so a batch run twice
        fills the gaps. A name carrying a random part is redrawn a few times
        first: on a counter a taken name is the account this series already
        made, but on a random draw it is only bad luck.


        A `planId` whose plan defines `namePrefix` / `nameSuffix` wraps every
        generated name, exactly as it wraps a single account's. Each account is
        also given its own credentials (`uuid`, `password`, `ss16`, `ss32`).
        A secret sent in the body's `config` would otherwise be the same secret
        on every account in the batch, and a body carrying none at all — which
        is what every caller sends — would leave the whole batch with no
        credentials and unusable share links.


        The licence cap and a reseller's own allowance are checked once for the
        whole batch and the request is refused (`402`) rather than trimmed — an
        operator told forty of a hundred fit has lost nothing, one silently given
        forty has to work out which sixty customers have no account. At most 500
        per call. The response carries the whole created rows, subscription
        tokens included, because handing the links over is the point.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/User"
                - type: object
                  required: [count, pattern]
                  properties:
                    count: { type: integer, minimum: 1, maximum: 500 }
                    pattern: { type: string, example: "user-{r3}-{nnn}" }
                    start: { type: integer, minimum: 0, default: 0 }
                    planId: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: array, items: { $ref: "#/components/schemas/User" } }
                  skipped: { type: array, items: { type: string }, description: "Names that were already taken" }
                  room: { type: integer, description: "How many more accounts this caller may create; -1 = uncapped" }
                  error: { type: string, description: "Set when the batch stopped early. The accounts in `created` exist and have been pushed; this reports why the rest were not made." }
        "400": { $ref: "#/components/responses/Error" }
        "402": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/users/export:
    get:
      parameters:
        - { name: format, in: query, schema: { type: string, enum: [csv, zip], default: csv } }
        - { name: ids, in: query, schema: { type: string }, description: "Comma-separated user ids. When absent, the filters below select the rows." }
        - { name: all, in: query, schema: { type: string, enum: ["true"] }, description: "Export every user the caller owns. Required when no `ids` and no filter are sent, so an omitted parameter can never mean *everything* — the same gate the bulk operations hold, and this route hands over subscription tokens." }
        - { $ref: "#/components/parameters/Q" }
        - { name: status, in: query, schema: { type: string } }
        - { name: online, in: query, schema: { type: boolean } }
        - { name: group, in: query, schema: { type: string } }
        - { name: admin_id, in: query, schema: { type: string, pattern: "^(me|[0-9]+)$" }, description: "As on `GET /api/users`: an id, `0`, or `me`." }
        - { name: template_id, in: query, schema: { type: integer, minimum: 1 } }
        - { name: node_id, in: query, schema: { type: integer, minimum: 1 } }
        - { name: inbound_id, in: query, schema: { type: integer, minimum: 1 } }
        - { name: endpoint_id, in: query, schema: { type: integer, minimum: 1 } }
        - { name: protocol, in: query, schema: { type: string } }
        - { name: expires_after, in: query, schema: { type: integer, format: int64, minimum: 0 } }
        - { name: expires_before, in: query, schema: { type: integer, format: int64, minimum: 0 } }
        - { name: expiry, in: query, schema: { type: string, enum: [never] } }
        - { name: pending, in: query, schema: { type: boolean } }
        - { name: used_min, in: query, schema: { type: integer, format: int64, minimum: 0 } }
        - { name: used_max, in: query, schema: { type: integer, format: int64, minimum: 0 } }
        - { name: auto_reset, in: query, schema: { type: boolean } }
        - { name: sub_fetched, in: query, schema: { type: boolean }, description: "Whether the subscription *body* has ever been handed to a client (`subFetchedAt > 0`). `false` is the account nobody has pointed a client at yet — distinct from `subSeenAt`, which the page and its polling also set." }
        - { name: contact_telegram_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.telegram_id`. `contact_email`, `contact_phone`, `contact_bale_id`, `contact_soroush_id` and `contact_rubika_id` are the same for the other standard keys; the free-text `q` matches all six as substrings." }
        - { name: contact_email, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.email`." }
        - { name: contact_phone, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.phone`. Matched as stored, so a number kept as `+98 912 …` is not found by `0912…`." }
        - { name: contact_bale_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.bale_id`, the chat id a Bale bot links." }
        - { name: contact_soroush_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.soroush_id`, the chat id a Soroush Plus bot links." }
        - { name: contact_rubika_id, in: query, schema: { type: string }, description: "Exact (case-insensitive) match on the account's `contact.rubika_id`, the chat id a Rubika bot links." }
        - { $ref: "#/components/parameters/UpdatedSince" }
        - { name: limit, in: query, schema: { type: integer, minimum: 0, maximum: 1000 }, description: "Parsed and range-checked (a negative or non-integer value is a 400) but **not applied**: this route acts on the whole selection, and `limit` does not cap it. Narrow the filter, or send `ids`." }
        - { name: offset, in: query, schema: { type: integer, minimum: 0 }, description: "Parsed and range-checked but **not applied**; see `limit`." }
        - { name: sort, in: query, schema: { type: string, enum: [id, name, usage, remaining, expiry, online_at, sub_fetched_at, sub_seen_at, created_at, updated_at] }, description: "Accepted and validated (a bad value is a 400); it does not change which rows the operation acts on." }
        - { name: order, in: query, schema: { type: string, enum: [asc, desc] }, description: "Accepted and validated; see `sort`." }
      tags: [users]
      summary: Download the selection as a sheet or a config pack
      description: >
        `csv` is the delivery and accounting sheet: one row per account with its
        limits, what it has spent and its subscription URL. Byte columns are
        bytes, not "1.5 GB" — the file is read by another program.


        `zip` is the offline half: one directory per account holding its
        subscription URL, its share links and every downloadable client config
        (`.conf`, `.ovpn`) it has.


        Selected by `ids` or by the same filters `GET /api/users` takes. Reach is
        the reach of `GET /api/users/{id}/links`, which this is the bulk spelling
        of: an operator sees every account, a reseller only its own. At most
        50000 rows for `csv` and 500 for `zip`; a wider selection answers `400`
        rather than a silently short file, as does a request that names no
        selection at all. The sheet begins with a UTF-8 BOM so a spreadsheet
        opening the downloaded file decodes it as UTF-8; a program reading the
        header row should strip it. Always recorded in the audit log, whether or not
        `changes_logging` is on — it is the one route that hands over
        credentials in bulk.
      responses:
        "200":
          description: OK
          content:
            text/csv: { schema: { type: string } }
            application/zip: { schema: { type: string, format: binary } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/users/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [users]
      summary: Read one user
      description: >
        One account in exactly the shape the list answers it, `subUrl`
        included. An addon keeps panel ids in its own database and reads the
        accounts back here. A reseller is answered `404` for an account it does
        not own, the same as for one that does not exist.
      responses:
        "200": { $ref: "#/components/responses/User" }
        "404": { $ref: "#/components/responses/Error" }
    patch:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [users]
      summary: Change some fields of a user
      description: >
        Writes exactly the fields sent and nothing else: every other column is
        left byte for byte as it was, so editing one field can never wipe the
        credentials or the limits the way a `PUT` that left them out does. The
        rules an edit is held to are the `PUT`'s — a reseller's reach and its
        `plansOnly` freeze, the licence cap, the role caps, `enable: false`
        recorded as a person's — and so is `adminId` (an account handed to a
        reseller must fit under its user cap).


        `version`, optional, is the account's `updatedAt` as the caller read
        it. When the account has changed since, the edit is refused with `409`
        and the current version, and nothing is written; read the account
        again and decide afresh. A write always moves the version forward, even
        within the second it counts in, so two edits from one version cannot
        both pass. A field sent with the value it already has is not a change:
        nothing is written and the version stays.


        A field that cannot be changed here — the usage counters, the
        subscription token, `planId`, anything unknown — is a `400` naming it,
        never silently ignored.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/UserPatch" } } }
      responses:
        "200": { $ref: "#/components/responses/User" }
        "400": { $ref: "#/components/responses/Error" }
        "402": { description: "The user is outside the licence cap, or the reseller named by `adminId` is at its role's user cap.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: "A reseller marked `usersReadOnly`, or the reseller named by `adminId` is at its own user cap or has expired.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "409":
          description: "The account changed since `version`. The body carries the current one."
          content:
            application/json:
              schema:
                type: object
                required: [error, version]
                properties:
                  error: { type: string }
                  version: { type: integer, format: int64 }
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [users]
      summary: Update a user
      description: >
        `planId` is ignored here. A plan is initialisation: re-applying a
        fixed-date one on every save would extend the account each time it was
        edited, so an existing user's limits are only ever changed field by
        field. Changing `duration` restarts the plan from the next connection;
        every other edit leaves the clock where it stands.


        A reseller marked `usersReadOnly` is answered `403` here and on
        `DELETE`. A `plansOnly` reseller may edit the name, description,
        credentials, remark and enable switch; every limit it sends — volume,
        expiry, duration, speed, address limit, the periodic reset, the
        template membership and the group — is replaced with the value already
        on the row, so an edit can never change what was sold.


        `adminId` is the one field a `PUT` reads as "unchanged" when it is
        absent: an owner silently reset to the panel by a client that never
        sent it would drop the account out of its operator's "my users".
      requestBody: { $ref: "#/components/requestBodies/User" }
      responses:
        "200": { $ref: "#/components/responses/User" }
        "400": { $ref: "#/components/responses/Error" }
        "402": { description: "The user is outside the licence cap (`licensed: false`): only the first N users by id are served, N being the licensed count. Delete rows to make room, or activate a licence. Delete itself is never refused for this reason.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [users]
      summary: Delete a user
      responses:
        "200": { description: Deleted }

  /api/plans:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [plans]
      summary: List plans
      description: >
        Plans are ordered by `sortOrder`, then by id. Readable by every
        operator and by a reseller, which needs them to create an account;
        only operators decide what is on offer. A `plansOnly` reseller is
        served exactly the plans it was granted, so the list it sees is the
        list it may sell.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Plan" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [plans]
      summary: Create a plan
      requestBody: { $ref: "#/components/requestBodies/Plan" }
      responses:
        "200": { $ref: "#/components/responses/Plan" }
        "400": { $ref: "#/components/responses/Error" }
  /api/plans/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [plans]
      summary: Update a plan
      description: >
        Users created from the plan are untouched: a plan is the state an
        account starts in, not a link it keeps.
      requestBody: { $ref: "#/components/requestBodies/Plan" }
      responses:
        "200": { $ref: "#/components/responses/Plan" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [plans]
      summary: Delete a plan
      description: >
        Withdraws the plan from sale. Accounts already created from it keep
        every limit they were given.
      responses:
        "200": { description: Deleted }

  /api/outbounds:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [pools]
      summary: List pool outbounds (selectable by templates)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Outbound" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Create a pool outbound
      description: >
        A body carrying `nodeId` — the per-node rows that are gone; a node's
        outbounds are its template's — is refused with 400 rather than stored
        as a row no template selects.
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Outbound" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Outbound" } } } }
        "400": { $ref: "#/components/responses/Error" }
  /api/endpoints:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [pools]
      summary: List pool endpoints (selectable by templates)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Endpoint" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Create a pool endpoint
      description: >
        Refused with 400 for an item only a client runs, and for an OpenVPN
        endpoint the node would refuse: a static key that is missing or is not
        one in static_key mode; control-channel protection (tls.control_wrap)
        with no or an unknown type, no key, a key and a key_path both, a key
        of the wrong kind or length, or a direction or force_cookie its type
        does not take; and a server with tls_auth, auth NONE and a data cipher
        that is not AEAD, whose users' profiles could not match it. So is a
        body carrying `nodeId`: a node's endpoints are its template's.
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } } }
        "400": { $ref: "#/components/responses/Error" }

  /api/presets:
    get:
      tags: [pools]
      summary: The preset catalogue
      description: >
        The shelf the panel answers "which rule set, and from where?" off,
        served rather than bundled into the interface so an operator can add to
        it without waiting for a release. It is the built-in catalogue with
        every `*.json` file in `preset_dir` merged onto it in filename order,
        matching by tag — a new tag is added, an existing one replaced, which is
        how an install re-points an entry at a mirror where the upstream is
        blocked. A file that does not parse is skipped rather than taking the
        catalogue with it, and `sources` names the files that were actually
        read, so a catalogue that ignored one is visible instead of silent.


        Labels are not translated here: a built-in entry carries `labelKey`, an
        i18n key the interface resolves, and an operator's own carries `label`,
        shown as written. The panel does not know the caller's locale, and a
        catalogue that had to be translated could not be edited by the person
        installing it.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PresetCatalogue" } } } }

  /api/rule-sets:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [pools]
      summary: List rule sets (remote lists the panel mirrors, selectable by templates)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/RuleSet" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Create a rule set
      description: >
        The panel downloads the list before the rule set exists: one it could
        not fetch and recognise is not created at all, so a rule can never be
        written against a tag that was never going to reach a node.
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/RuleSet" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/RuleSet" } } } }
        "409": { description: Tag already taken, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: The upstream could not be downloaded or is not a rule set, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /api/rule-sets/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Update a rule set
      description: >
        The tag is fixed once any rule matches on it: renaming would leave those
        rules naming a rule set that no longer exists, which the node refuses its
        whole config for.
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/RuleSet" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/RuleSet" } } } }
        "409": { description: Tag already taken or fixed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: A changed upstream could not be downloaded, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Delete a rule set
      responses:
        "200": { description: Deleted }
        "409": { description: Still referenced by a route or DNS rule, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /api/rule-sets/{id}/refresh:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Re-download a rule set from its upstream now
      description: >
        A failed refresh keeps the previous copy and records the error: an
        upstream being briefly down must not remove a list — and the rules
        naming it — from a whole fleet's routing.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/RuleSet" } } } }
        "502": { description: The upstream could not be downloaded, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api/inbounds:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [inbounds]
      summary: List inbounds
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Inbound" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [inbounds]
      summary: Create an inbound
      requestBody: { $ref: "#/components/requestBodies/Inbound" }
      responses: { "200": { $ref: "#/components/responses/Inbound" } }
  /api/inbounds/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [inbounds]
      summary: Update an inbound
      requestBody: { $ref: "#/components/requestBodies/Inbound" }
      responses: { "200": { $ref: "#/components/responses/Inbound" } }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [inbounds]
      summary: Delete an inbound
      responses: { "200": { description: Deleted } }
  /api/inbounds/{id}/fronts:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [inbounds]
      summary: Set which domain fronts are enabled on this inbound
      description: >
        The same `inbound_fronts` join `PUT /api/fronts/{id}` writes from the
        other end. An inbound lists its fronts and a front lists its inbounds;
        whichever the operator is looking at is the one they edit. An empty list
        removes every front from this inbound. When the change moves which
        client-address headers an XHTTP inbound trusts (see `clientIpHeader`
        on DomainFront), the inbound is re-sent to every node serving it, which
        restarts it there and drops its live connections.


        Refused when the inbound's protocol cannot sit behind a CDN, exactly as
        the front side refuses it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                frontIds: { type: array, items: { type: integer } }
      responses:
        "200": { description: "The fronts now enabled on this inbound", content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/DomainFront" } } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { description: "More than the per-inbound maximum", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api/templates:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [templates]
      summary: List templates
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Template" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [templates]
      summary: Create a template
      requestBody: { $ref: "#/components/requestBodies/Template" }
      responses: { "200": { $ref: "#/components/responses/Template" } }
  /api/templates/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [templates]
      summary: Update a template
      requestBody: { $ref: "#/components/requestBodies/Template" }
      responses: { "200": { $ref: "#/components/responses/Template" } }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [templates]
      summary: Delete a template
      responses: { "200": { description: Deleted } }

  /api/nodes:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [nodes]
      summary: List nodes
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Node" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Create a node
      description: >-
        `name` and `address` (a host name or an IP address, without scheme or
        port) are required; an omitted `port` is 62050.
      requestBody: { $ref: "#/components/requestBodies/Node" }
      responses:
        "200": { $ref: "#/components/responses/Node" }
        "400": { description: "A field is missing or malformed; the message names it (`name:`, `address:`, `port:`, `link addresses:`).", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /api/nodes/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Update a node
      description: >-
        Replaces every editable field, so the body is the whole row: `name`,
        `address` and a `port` from 1 to 65535 are required, and an omitted
        optional field is cleared.
      requestBody: { $ref: "#/components/requestBodies/Node" }
      responses:
        "200": { $ref: "#/components/responses/Node" }
        "400": { description: "A field is missing or malformed; the message names it.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "402": { description: "The node is outside the licence cap (`licensed: false`): only the first N nodes by id are served. The same answer comes from `/enabled`; delete is never refused for this reason.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Delete a node
      description: >-
        Stops the node's engine as it is deleted, so it serves nothing more.
        `stopped` is false when the node could not be reached: it keeps
        serving what it had until its process restarts.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                required: [status, stopped]
                properties:
                  status: { type: string }
                  stopped: { type: boolean }

  /api/nodes/{id}/sync:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post: { tags: [nodes], summary: Push the full config to the node, responses: { "200": { description: OK }, "409": { description: The node is switched off } } }
  /api/nodes/{id}/reconnect:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post: { tags: [nodes], summary: Re-establish the mTLS connection, responses: { "200": { description: OK }, "409": { description: The node is switched off } } }
  /api/nodes/install/versions:
    get:
      tags: [nodes]
      summary: Node binaries this panel has staged, and their versions
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  panelVersion: { type: string }
                  defaultImage: { type: string }
                  arches: { type: array, items: { type: string } }
                  staged:
                    type: array
                    items:
                      type: object
                      properties:
                        arch: { type: string }
                        version: { type: string, description: "Empty when the panel cannot tell which release the binary is" }
                        size: { type: integer, format: int64 }
                        modTime: { type: integer, format: int64 }
  /api/nodes/{id}/install/probe:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [nodes]
      summary: Inspect a node's host over SSH and report the install plan
      description: >
        First contact pins the host's SSH key; afterwards a different key is a
        409, because the answer to that is never "retry".
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/SSHCredentials" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ProbeResult" } } } }
        "401": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/install:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [nodes]
      summary: Install, update or remove the node on its server over SSH
      description: >
        Returns immediately with the job to watch; the install runs in the
        background. One install at a time per node.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/InstallRequest" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/InstallJob" } } } }
        "401": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /api/nodes/install/jobs/{job}:
    parameters:
      - { name: job, in: path, required: true, schema: { type: string } }
    get:
      tags: [nodes]
      summary: Poll an install job's state and output
      parameters:
        - { name: cursor, in: query, schema: { type: integer }, description: "Lines already seen; 0 for the whole log" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/InstallJob" }
                  lines: { type: array, items: { type: string } }
                  cursor: { type: integer }
        "404": { $ref: "#/components/responses/Error" }
  /api/nodes/install/jobs/{job}/cancel:
    parameters:
      - { name: job, in: path, required: true, schema: { type: string } }
    post:
      tags: [nodes]
      summary: Abort a running install
      description: >
        Signals the remote script and drops the connection. Whatever it had
        already done on the host stays done.
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/Error" }
  /api/nodes/install/jobs/{job}/stream:
    parameters:
      - { name: job, in: path, required: true, schema: { type: string } }
    get:
      tags: [nodes]
      summary: WebSocket stream of an install job's output
      description: >
        Upgrades to a WebSocket; each message is a JSON {job, lines, cursor} —
        the same shape the polling endpoint returns. The socket closes once the
        job has finished and its last lines are sent.
      responses:
        "101": { description: Switching Protocols }
        "404": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/move:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [nodes]
      summary: Point a node at a different server
      description: >
        Changing the address alone is not enough: the control API's certificate
        pin, the SSH host key and the cached deployment facts all belong to the
        old machine and would refuse or misdirect every connection to the new
        one. This drops all three together and is the only safe way to move a
        node.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address: { type: string }
                port: { type: integer }
                sshHost: { type: string }
                sshPort: { type: integer }
                sshUser: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Node" }
        "400": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/ssh/revoke:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [nodes]
      summary: Remove the panel's SSH key from the node's server
      description: >
        The key is one key for every host, so it stays in authorized_keys
        when another node or an addon the panel installed is on the same
        machine (by any of its names or addresses) at the same login, or when
        that cannot be ruled out because a lookup failed. Then keyKept is true,
        reason says why and keptFor names the record it stays for, when one was
        found. Either way this node no longer uses the key: the last record on
        the machine to be revoked removes it. A record whose name does not
        resolve at all is left out of the comparison and named in the log (and
        in reason when the key stays).
      requestBody:
        required: false
        content: { application/json: { schema: { $ref: "#/components/schemas/SSHCredentials" } } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [status, keyKept, log]
                properties:
                  status: { type: string }
                  keyKept: { type: boolean, description: "The key stayed on the machine for another record; this node no longer uses it." }
                  reason: { type: string, description: "Why the key stayed; only when keyKept is true." }
                  keptFor:
                    type: object
                    description: The record the key stayed for; only when keyKept is true and one was found on the machine.
                    required: [kind, name]
                    properties:
                      kind: { type: string, enum: [node, addon] }
                      name: { type: string, description: "The node's name or the addon's slug." }
                  log: { type: array, items: { type: string } }
        "401": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/repin:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post: { tags: [nodes], summary: Re-pin the node server certificate, responses: { "200": { description: OK } } }
  /api/nodes/{id}/enabled:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Enable/disable a node
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: { enabled: { type: boolean } }, required: [enabled] } } }
      responses: { "200": { description: OK } }
  /api/nodes/{id}/template:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Assign an outbound/route template to a node
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: { templateId: { type: integer, nullable: true } } } } }
      responses: { "200": { description: OK } }
  /api/fronts:
    get:
      tags: [pool]
      summary: The domain-front catalogue
      description: >
        CDN routes (Cloudflare, Fastly) in front of inbounds. A front hangs off
        the **inbound**, not a node: the CDN picks its origin by SNI, so one
        front covers however many nodes serve that inbound.


        It renders **one entry per inbound**, never one per node — node
        addresses and fronts add, they never multiply. Links are rendered per
        request, so an edit is live on the next subscription fetch. The one
        thing a node is told is `clientIpHeader`: when a save or a delete
        changes which headers an XHTTP inbound trusts (the header itself,
        enabling, disabling or deleting a front that has one, attaching or
        detaching it), that inbound is re-sent to every node serving it, which
        restarts it there and drops its live connections. Any other edit sends
        nothing.
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/DomainFront" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pool]
      summary: Add a domain front
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/DomainFrontInput" } } }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/DomainFront" } } } }
        "400": { description: "Invalid, or an inbound named here cannot sit behind a CDN — REALITY, QUIC (hysteria/hysteria2/tuic), port hopping, or a transport a CDN cannot forward", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: "The catalogue is full, or an inbound already carries the maximum number of fronts", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /api/fronts/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pool]
      summary: Edit a domain front
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/DomainFrontInput" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/DomainFront" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    delete:
      tags: [pool]
      summary: Remove a domain front
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/stats:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      parameters:
        - { name: hours, in: query, schema: { type: integer, default: 1 }, description: "Preset window, counted back from now. Ignored when start/end are given" }
        - { name: start, in: query, schema: { type: integer, format: int64 }, description: "Window start (unix seconds); must be given with end" }
        - { name: end, in: query, schema: { type: integer, format: int64 }, description: "Window end (unix seconds); must be given with start" }
      tags: [nodes]
      summary: Per-user traffic on a node over a window
      description: >
        Who spent what on this node, one row per user, summed over the window
        and heaviest first. The window vocabulary is `GET /api/stats`'s. Raw
        bytes as recorded on the node; the node multiplier scales what a user
        is charged, not this figure. A user deleted since the traffic was
        recorded keeps its row with an empty `name`.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/NodeUsage" } } } }
        "400": { $ref: "#/components/responses/Error" }
  /metrics:
    get:
      tags: [nodes]
      summary: Prometheus exposition
      description: >
        The panel's own figures in the Prometheus text exposition format, for a
        scrape config to pull. Deliberately **outside `/api`**, and the one
        authenticated route with no `/api/v1` alias: a scrape config names a
        path, and `/metrics` is the one every operator and dashboard expects.


        It is authenticated like everything else — `stats:read`, so an API token
        with that scope and `Authorization: Bearer` is what a scrape config
        carries. There is no unauthenticated mode: the document names every node
        and its address, and an endpoint that hands that out cannot be
        un-shipped once installs are running it. Put it behind your own proxy if
        you want it open.


        **No series carries a per-user label.** That is a design rule, not an
        omission: one label per account on a ten-thousand-user panel is ten
        thousand series that never retire. The series count is bounded by the
        node count. "Who spent what" is `GET /api/reports/top-users`, which can
        be asked about a window instead of scraped for ever.


        **A figure a node did not report has no series** rather than a zero one —
        disk, memory, load, connections, engine restarts, rejected connections
        (`nx_node_rejected_connections_total`, one series per block outbound
        the node has actually sent something to, labelled by its tag) and the
        per-node online count are all conditional, because zero means "did not say" everywhere
        else in this API and a zero here draws a graph in which the disk emptied
        itself. The one deliberate exception is `nx_event_deliveries`, which
        always reports every status, so an alert on dead deliveries can evaluate
        on a healthy panel.
      responses:
        "200":
          description: OK
          content:
            text/plain:
              schema: { type: string }
  /api/nodes/{id}/health:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      parameters:
        - { name: hours, in: query, schema: { type: integer, default: 1 }, description: "Preset window, counted back from now. Ignored when start/end are given" }
        - { name: start, in: query, schema: { type: integer, format: int64 }, description: "Window start (unix seconds); must be given with end" }
        - { name: end, in: query, schema: { type: integer, format: int64 }, description: "Window end (unix seconds); must be given with start" }
      tags: [nodes]
      summary: A node's disk, memory and load over a window, with its uptime bar
      description: >
        The stored readings behind the live figure on the node row, plus the
        uptime bar derived from them. Both come from one call because they are
        the same evidence read two ways: a reading is a point on the chart and
        proof the node was up in that interval, so two routes could disagree.


        The five-minute sampler stores one reading per connected node; readings
        older than seven days are folded to one an hour, in place, so `samples`
        may carry either resolution and `bucket` says which the bar was derived
        at (300 or 3600). A reading whose disk and memory totals are both zero
        is never stored — zero means the node did not say — so an absent point
        is not a zero one.


        `uptime` tiles the window exactly once with runs of equal `state`:
        `up` (the panel was running and the node reported), `down` (the panel
        was running and the node did not), and `unknown`, which is three
        causes that all mean "we cannot say" — the panel itself was off, the
        interval predates the node, or this node has **never** produced a
        reading, which is an engine too old to report host metrics rather than
        an outage. The panel's own uptime comes from `panel_runs`, a row per
        process, because `panel.started` is a notification that is only
        written when a subscriber wants it and is pruned after a week.


        `recording` is false when `node_health_retention_days` is 0, so an empty
        answer reads as "switched off" rather than "no history".
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/NodeHealth" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/reports/top-users:
    get:
      parameters:
        - { $ref: "#/components/parameters/ReportHours" }
        - { $ref: "#/components/parameters/ReportStart" }
        - { $ref: "#/components/parameters/ReportEnd" }
        - { $ref: "#/components/parameters/ReportLimit" }
      tags: [nodes]
      summary: Heaviest users across the fleet over a window
      description: >
        One row per user, summed over every node they were seen on, heaviest
        first. `nodes` counts the distinct nodes a user spent traffic on in the
        window. Read from the hourly rollups rather than the `stats` series, so
        the answer survives past the graph retention window and is unaffected by
        graphing being switched off. A user deleted since the traffic was
        recorded keeps its row with an empty `name`.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/TopUsers" } } } }
        "400": { $ref: "#/components/responses/Error" }
  /api/reports/node-usage:
    get:
      parameters:
        - { $ref: "#/components/parameters/ReportHours" }
        - { $ref: "#/components/parameters/ReportStart" }
        - { $ref: "#/components/parameters/ReportEnd" }
        - { $ref: "#/components/parameters/ReportLimit" }
      tags: [nodes]
      summary: Traffic per node over a window
      description: >
        What each node moved over the window, heaviest first. The node list
        carries lifetime totals and current-period usage against the node's
        limit; this is the same figure over a window the caller chooses. Raw
        bytes — the multiplier scales what a user is charged, not this.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/NodeTotals" } } } }
        "400": { $ref: "#/components/responses/Error" }
  /api/users/{id}/node-usage:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      parameters:
        - { $ref: "#/components/parameters/ReportHours" }
        - { $ref: "#/components/parameters/ReportStart" }
        - { $ref: "#/components/parameters/ReportEnd" }
        - { $ref: "#/components/parameters/ReportLimit" }
      tags: [users]
      summary: One user's traffic split across nodes
      description: >
        The mirror of `GET /api/nodes/{id}/stats`: that one answers "this node,
        which users", this one "this user, which nodes". A node deleted since
        the traffic was recorded keeps its row with an empty `name`.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/UserNodeUsage" } } } }
        "400": { $ref: "#/components/responses/Error" }
  /api/stats:
    get:
      parameters:
        - { name: resource, in: query, required: true, schema: { type: string, enum: [user, inbound, outbound, endpoint, node] }, description: "What the tag names" }
        - { name: tag, in: query, required: true, schema: { type: string }, description: "The user name, the inbound/outbound/endpoint tag, or — for resource=node — the node's name. An unknown node name answers 404." }
        - { name: nodeId, in: query, schema: { type: integer }, description: "Narrow to one node; 0 or absent sums every node the tag runs on" }
        - { name: hours, in: query, schema: { type: integer, default: 1 }, description: "Preset window, counted back from now. Ignored when start/end are given" }
        - { name: start, in: query, schema: { type: integer, format: int64 }, description: "Window start (unix seconds); must be given with end" }
        - { name: end, in: query, schema: { type: integer, format: int64 }, description: "Window end (unix seconds); must be given with start" }
      tags: [nodes]
      summary: Traffic over time for one user, inbound, outbound or endpoint
      description: >
        The time series behind the usage graphs, downsampled to at most 360
        points. `up` and `down` are dense arrays of equal length, one entry per
        bucket, in bytes; the timestamp of point `i` is
        `startTime + i * bucketSpan`. A bucket with no traffic is a zero, not a
        gap — the collector runs whether or not anything is flowing.


        `bucketSpan` is always a whole multiple of the `stats_bucket_seconds`
        setting, and the returned window is rounded outwards onto that grid, so
        it can be slightly wider than the one requested.


        Samples are kept for `stats_retention_days` (default 30); setting that
        to 0 stops the recording and empties the table, after which every
        series answers with zeros. Traffic is raw bytes — a node's multiplier
        scales what a user is charged, not what crossed the wire.


        Resellers may read their own users only; every other resource and
        anyone else's user answers 403.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/StatsSeries" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/logs/stream:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [nodes]
      summary: Follow the node engine log over a WebSocket
      description: >
        Upgrades to a WebSocket that starts at the oldest line the node still
        holds and then follows the engine's log: every second the node is
        asked for what is new, and when there is something it is sent as one
        text frame carrying a JSON `{lines, cursor}` — the shape of
        `GET /api/nodes/{id}/logs`, the polled form. A node that does not
        answer sends nothing until it does; the socket stays open until the
        client closes it. Operator-only.
      responses:
        "101": { description: Switching to the WebSocket protocol }
        "400": { description: "An invalid id (JSON `Error`), or a malformed WebSocket handshake (plain text)" }
        "403": { description: A cross-origin handshake (plain text) }
        "426": { description: A request that is not a WebSocket upgrade (plain text) }
  /api/nodes/{id}/logs:
    parameters:
      - { $ref: "#/components/parameters/Id" }
      - { name: since, in: query, schema: { type: integer, format: int64 }, description: Cursor from the previous call }
    get:
      tags: [nodes]
      summary: Tail the node engine logs
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  lines: { type: array, items: { type: string } }
                  cursor: { type: integer, format: int64 }
  /api/nodes/{id}/info:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [nodes]
      summary: Node host + runtime metrics (cpu/mem/uptime)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ServerInfo" } } } }
        "502": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/connections:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [nodes]
      summary: Live connections on this node, per user and inbound
      description: >
        Read from the node on demand: the answer is only true for the seconds
        somebody is looking at it, and nothing is stored. The total and the
        per-inbound rollup are the same figures the node reports on every
        heartbeat (see `Node.live`); the per-user rows are what this call
        exists for.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/NodeConnections" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/connections/close:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Drop one user's live connections on this node
      description: >
        The same close as `POST /api/users/{id}/connections/close`, reached
        from the node's connections drawer where the row names a user and the
        node is already chosen. The user is named, not numbered, because the
        node knows names — one deleted while still connected can still be
        dropped. Operator-only, like the drawer; costs the `users:write`
        scope, because it acts on a user's sessions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/CloseConnsFilter"
                - type: object
                  required: [user]
                  properties:
                    user: { type: string, description: The user's name }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/CloseConnsResult" } } } }
        "502": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/log-level:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Set the node engine log level live (no restart)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [level]
              properties:
                level: { type: string, enum: [trace, debug, info, warn, error, fatal, panic] }
      responses: { "200": { description: OK }, "409": { description: The node is switched off } }
  /api/nodes/{id}/check-outbound:
    parameters:
      - { $ref: "#/components/parameters/Id" }
    get:
      tags: [nodes]
      summary: The outbounds and endpoints a check can run on
      description: >
        Read from the node's running inventory rather than from its template,
        so the built-in direct and a relay's tunnel are listed too. Each row
        carries the panel's type for the tag when the panel has one, and
        `tunnel` for a tunnel. Operator-only.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/CheckOutboundRow" } } } } }
        "404": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { name: tag, in: query, required: true, schema: { type: string }, description: Outbound or endpoint tag }
        - { name: link, in: query, schema: { type: string }, description: "Test URL (default: generate_204)" }
      tags: [nodes]
      summary: URL-test an outbound/endpoint by tag (latency check)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/OutboundCheckResult" } } } }
        "502": { $ref: "#/components/responses/Error" }

  /api/nodes/{id}/route-test:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [nodes]
      summary: The inbounds a route test can start from on this node
      description: >
        Read from the node's running inventory rather than from its template,
        so a tunnel's inbound on an origin — where a relayed user's
        connections arrive — is listed too. Each row carries the panel's type
        and remark for the tag when the panel has one. Operator-only.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/RouteTestForm" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: How the node would route a described connection
      description: >
        The node walks its own route rules for the connection, in order, and
        answers with every rule it tried, the one that ended the walk and the
        outbound or tunnel the connection goes to. Nothing is dialed and
        nothing is sniffed, so the sniffed protocol and domain are the
        caller's to supply; a sniff rule given neither is marked skipped.
        A node older than the route answers 501. Operator-only.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/RouteTestRequest" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/RouteTestResult" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "501": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/route-test/dial:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: The route test, then one real connection down the path it found
      description: >
        The same walk, followed by one TCP connection from the node through
        the outbound it ended at, to the destination it ended with. Nothing is
        sent on it. Through a tunnel or proxy the node then holds it for six
        seconds, longer than a far node takes to give up on a destination: a
        far end that could not reach it closes the connection (reported as an
        error); through a direct outbound the connect itself is the answer. A
        destination that speaks first is reported as answered; one still
        silent at the end of a hold through a tunnel or proxy is OK but
        unconfirmed, since a far end still trying looks the same. UDP, a rejected
        route and an outbound the router would refuse are not dialed, and the
        result says why. Needs nodes:write, like check-outbound.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/RouteTestRequest" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/RouteTestResult" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "501": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /api/nodes/{id}/ruleset-test:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Which of the node's loaded rule sets an address falls in
      description: >
        Only rule sets the node has loaded can answer. With no tags, every rule
        set the node's route declares answers, in its order; a tag the node
        never loaded answers loaded false. A node older than the route answers
        501. Operator-only.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/RuleSetTestRequest" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/RuleSetTestResult" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "501": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/users/{id}/addresses:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [users]
      summary: Where the user is connected from right now
      description: >
        The source prefixes the fleet currently sees for this user, merged
        from every node's report and newest first. Live and memory-only: an
        address stays for ten minutes after its last connection, and a panel
        that has just restarted answers an empty list until the next round of
        node reports a few seconds later. A reseller may read its own users.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/UserAddresses" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/users/{id}/connections:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [users]
      summary: Where the user is connected right now, per node and inbound
      description: >
        Asked of every connected node when the call is made and filtered to
        this user — one row per node and inbound with the number of sessions
        open there. A node that does not answer in time is left out. Empty,
        never null, for a fleet with nothing to say. A reseller may read its
        own users.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/UserConnections" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/users/{id}/connections/{nodeId}:
    parameters:
      - { $ref: "#/components/parameters/Id" }
      - { name: nodeId, in: path, required: true, schema: { type: integer } }
      - { name: inbound, in: query, schema: { type: string }, description: Only rows that arrived on this inbound tag }
      - { name: outbound, in: query, schema: { type: string }, description: Only rows routed to this outbound tag }
    get:
      tags: [users]
      summary: The user's live connections on one node, row by row
      description: >
        Read from that node when the call is made. One node by design: the
        rollup above is the cheap fleet-wide answer that says which node to
        ask, and only the node asked walks its registry. `inbound` and
        `outbound` narrow the rows alone; `total`, `inbounds` and `outbounds`
        describe the user's whole presence on the node, so a picker built
        from them keeps its options. Rows are newest first and cut at the
        node's cap, with `truncated` saying so. `supported` is false for a
        node that predates per-session rows: it still counts them. Nothing
        is stored. A reseller may read its own users.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/UserSessions" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/users/{id}/connections/close:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [users]
      summary: Drop the user's live connections
      description: >
        Closes the connections the body selects: on one node (`nodeId`) or
        every connected node, one inbound or outbound, or — with an empty
        body — all of them everywhere. No single-row close: a connection is
        seconds of life and the client opens the next one unasked. Not a ban: the client reconnects on its next packet
        unless the user is also disabled. Sent to one node it fails as that
        node fails; sent to the fleet it reports what each node could do and
        fails nothing. Audited. A reseller may act on its own users.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloseConnsFilter" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/CloseConnsResult" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/users/{id}/devices:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [users]
      summary: Devices holding the user's subscription
      description: >
        Every device that has fetched this user's subscription and identified
        itself with an `x-hwid` header, newest activity first, with the
        platform, OS version and model the client reported. A record of
        fetches, not of connections: the node never learns a device id. A
        reseller may read its own users.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/UserDevices" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/users/{id}/devices/{hwid}:
    parameters:
      - { $ref: "#/components/parameters/Id" }
      - { name: hwid, in: path, required: true, schema: { type: string } }
    delete:
      tags: [users]
      summary: Forget a device
      description: >
        Frees the device's place under the user's `deviceLimit`. The device is
        not cut off — it holds the configs already and the node authenticates
        by credential — but its next fetch, and any new device's, is judged
        against the cap again. How an operator lets a customer who replaced
        their phone back in.
      responses:
        "200": { description: Forgotten }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/users/{id}/links:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [users]
      summary: The account's share links, one per allowed inbound entry
      description: >
        The same entries the plain subscription carries — every node the
        account's templates reach, each inbound its `configFilter` allows,
        fanned out over the node's link addresses and the inbound's domain
        fronts — as share links, for per-config QR codes. A reseller is
        answered 404 for an account it does not own.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [links]
                properties:
                  links: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/users/{id}/app-configs:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [users]
      summary: Dedicated-app client configs (OpenVPN .ovpn / OpenConnect info / WireGuard template) across the user's nodes
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  configs:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        type: { type: string, enum: [openvpn, openconnect, wireguard] }
                        fileName: { type: string }
                        content: { type: string }
        "404": { $ref: "#/components/responses/Error" }

  /api/certificates:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [certificates]
      summary: List panel certificates (shared TLS certs assignable to inbounds/endpoints, usable as node fallbacks)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/PanelCertificate" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [certificates]
      summary: Define a panel certificate and issue it (self-signed locally, ACME via the designated node)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/PanelCertRequest" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PanelCertificate" } } } }
        "502": { $ref: "#/components/responses/Error" }
  /api/certificates/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [certificates]
      summary: Update a panel certificate (a pure rename keeps the PEM; parameter changes reissue it and resync using nodes)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/PanelCertRequest" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PanelCertificate" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [certificates]
      summary: Delete a panel certificate, clearing assignments/fallbacks and resyncing the nodes that used it
      responses:
        "200": { description: OK }
        "409":
          description: >
            The certificate serves the panel's own HTTPS listener
            (web_tls_mode=self_signed with web_cert_id pointing at it). Deleting
            it would leave the panel with a TLS mode and nothing behind it, so
            change the mode first. The same for any listener of the ingress
            serving it, as its own certificate or among its SNI certificates,
            and for one the change waiting for its confirmation would put back.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /api/certificates/{id}/renew:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [certificates]
      summary: Reissue the panel certificate with its stored parameters and resync using nodes
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PanelCertificate" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }

  /api/nodes/{id}/certificate:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [nodes]
      summary: The node's active managed TLS certificate (null when none)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Certificate", nullable: true } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Issue (self-signed) or obtain (ACME) the node's certificate
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/CertRequest" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Certificate" } } } }
        "502": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Remove the node's managed certificate (inbounds fall back to inline)
      responses: { "200": { description: OK } }
  /api/nodes/{id}/certificate/renew:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Reissue the node's certificate with its stored parameters
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Certificate" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }

  /api/nodes/{id}/certificate/client:
    parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Update the node certificate's client-side TLS template (no reissue, no resync)
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: { client: { $ref: "#/components/schemas/JSON" } } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Certificate" } } } }
  /api/tools/ech-keypair:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [tools]
      summary: Generate an ECH keypair (server "ECH KEYS" PEM + client "ECH CONFIGS" PEM) for a public name
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [publicName]
              properties:
                publicName: { type: string, description: "The outer SNI clients present" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { type: string, description: "Server side, goes in the inbound's tls.ech.key" }
                  config: { type: string, description: "Client side; subscriptions derive it from the key automatically" }
        "400": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/tools/link-name-preview:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [tools]
      summary: Render a link-name template against sample entries
      description: >
        What `sub_link_name_template` would name three kinds of entry: an
        ordinary one on a node, a domain-front one (which carries no node, so
        `{NODE}` is empty there), and an endpoint (no route, no transport).


        Rendered by the same code the subscription uses rather than mirrored in
        the client, because the rules are not guessable from the field: an
        unknown variable is kept verbatim so a typo is visible, and an empty one
        takes the separator it stranded with it. `problem` is what a save of this
        template would be refused with, and is empty when it would be accepted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                template: { type: string, description: "Empty previews the fixed format that predates the setting" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  samples:
                    type: array
                    items:
                      type: object
                      properties:
                        kind: { type: string, enum: [direct, front, endpoint] }
                        name: { type: string }
                  problem: { type: string, description: "Why a save would be refused; empty when it would not" }
        "400": { $ref: "#/components/responses/Error" }
  /api/tools/reality-keypair:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [tools]
      summary: Generate a REALITY x25519 keypair and short id (or derive the public key of a supplied private key)
      requestBody:
        required: false
        content: { application/json: { schema: { type: object, properties: { privateKey: { type: string } } } } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  privateKey: { type: string }
                  publicKey: { type: string }
                  shortIds: { type: array, items: { type: string }, description: "A fresh pool of 24 random-length short ids" }
        "400": { $ref: "#/components/responses/Error" }

  /api/tools/vless-encryption:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [tools]
      summary: Mint a VLESS Encryption server string (the inbound's `decryption`), or derive the client string of one
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                authentication: { type: string, enum: [x25519, mlkem768], description: "Which key authenticates the server: X25519 (not post-quantum) or ML-KEM-768 (post-quantum). Read only when decryption is empty." }
                mode: { type: string, enum: [native, xorpub, random], description: "The xor mode of the layer. Read only when decryption is empty." }
                seconds: { type: integer, minimum: 0, maximum: 65535, description: "How long a session ticket lets a client reconnect in 0-RTT; 0 for never, 65535 at most (the node's conservative cap on the lifetime it tells clients in two bytes). Read only when decryption is empty." }
                decryption: { type: string, description: "An existing server string, to derive its client string from" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  decryption: { type: string, description: "The server string; goes in the inbound's config as `decryption` and is a secret" }
                  encryption: { type: string, description: "The client string; every link and outbound carries it" }
                  authentication: { type: string, enum: [x25519, mlkem768] }
        "400": { $ref: "#/components/responses/Error" }

  /api/outbounds/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Update a pool outbound
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Outbound" } } } }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Outbound" } } } } }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Delete a pool outbound
      responses: { "200": { description: Deleted } }
  /api/endpoints/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Update a pool endpoint
      description: >
        Refused with 400 for an item only a client runs, and for an OpenVPN
        endpoint the node would refuse: a static key that is missing or is not
        one in static_key mode; control-channel protection (tls.control_wrap)
        with no or an unknown type, no key, a key and a key_path both, a key
        of the wrong kind or length, or a direction or force_cookie its type
        does not take; and a server with tls_auth, auth NONE and a data cipher
        that is not AEAD, whose users' profiles could not match it.
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } } }
        "400": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Delete a pool endpoint
      responses: { "200": { description: Deleted } }

  /api/settings:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [settings]
      summary: List settings (key/value)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Setting" } } } } }
  /api/settings/{key}:
    parameters: [ { name: key, in: path, required: true, schema: { type: string } } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Create/update a setting
      description: >
        Under /api/v1 the thirteen address keys the ingress replaced
        (web_domain, web_basepath, sub_domain, sub_basepath, the four TLS keys,
        the two listen keys, web_masquerade_dir, public_address,
        trusted_proxies) answer 409 with `moved: ingress`: they are written
        with PUT /api/v1/ingress. Under /api they are still a view of the
        ingress while it is one they describe.
        Panel-wide keys (web_domain, web_basepath, web_cert_file, web_key_file,
        web_listen_ip, web_listen_port, web_tls_mode, web_cert_id,
        acme_directory, web_masquerade_dir, preset_dir, sub_domain, sub_basepath, timezone,
        event_retention_days, node_health_retention_days, ruleset_dir,
        trusted_proxies,
        login_allowlist, and the backup_* schedule keys) are sudo-only, and for an account with
        two-factor authentication on they answer 428 until the second factor
        is presented again (POST /api/me/confirm). trusted_proxies and
        login_allowlist are comma- or space-separated IP addresses and CIDRs
        and answer 400 otherwise; web_* keys apply after a
        Panel restart, backup_* keys on the next hourly tick.
        node_health_retention_days is how many days of per-node disk, memory
        and load readings to keep (empty = 90, 0 = stop recording and empty the
        table on the next hourly pass, otherwise 1-730 or 400); inside that
        window the last 7 days keep their five-minute resolution and everything
        older is folded to one reading an hour, in place. Other keys accept
        any admin; among them alert_disk_percent and alert_memory_percent, the
        thresholds the five-minute host sampler raises node.disk_high /
        node.memory_high at (empty = 90, 0 = off, otherwise 1–100 or 400),
        alert_rejected_per_hour, the line node.rejections_high is raised at —
        connections one block outbound refused inside a sliding hour, read
        from the node's own tally on every heartbeat (empty = 100, 0 = off,
        otherwise a non-negative count or 400), and
        the two account thresholds the minute-by-minute quota enforcer raises
        user.quota_warning and user.expiring at: alert_user_volume_percent, a
        share of the account's own cap (empty = 80, 0 = off, otherwise 1–100 or
        400), and alert_user_expiry_days, a comma list of days before the
        account's date (empty = "7,1", 0 = off, otherwise positive day counts
        or 400). Each warning is said once per cap or per expiry and re-arms
        itself when either moves; a cycle reset that drops usage back under the
        line re-arms the volume one. Two keys are never writable here and answer 403 for anyone:
        setup_token (written only by the first-run wizard) and
        backup_passphrase (written by PUT /api/backups/schedule, or by
        `nx-panel config set` on a headless host) — this route echoes the
        saved row back, and both are bearer secrets. The same two are omitted
        from GET /api/settings. Several keys are validated on write and answer 400 when
        malformed: web_listen_ip (an IP literal, or empty to keep the address
        the process started with), web_listen_port (1-65535, or empty), timezone
        (an IANA zone name such as Asia/Tehran, or empty for the host zone) -
        the zone database is compiled into the panel, so no system tzdata is
        required - acme_directory and sub_support_url (an absolute http(s) URL,
        or empty), web_tls_mode (none | self_signed | files) and web_cert_id (a
        number, or empty). ruleset_dir must be an absolute path, or empty for the directory beside
        the database. The backup keys are checked here too, since this
        route and `nx-panel config set` reach the same rows the schedule
        endpoint guards: backup_dir (an absolute path, or empty for the default
        beside the database), backup_interval_hours (a number of hours up to
        8760, or empty for the daily default), backup_keep (a number of
        archives, 0 = keep everything) and backup_enabled /
        backup_include_history / backup_include_audit (true or false).
        The panel's address keys (web_domain, web_basepath, sub_domain,
        sub_basepath, the four TLS keys, the two listen keys,
        web_masquerade_dir, public_address and trusted_proxies) have no rows of
        their own: they read and write the ingress (GET /api/ingress) in its
        one-listener shape, and answer 409 for a change once the ingress holds
        more than they can describe. Writing back the value a key already has
        succeeds. Their entries in GET /api/settings carry no id. A change to
        one needs the ingress:write permission too (403 otherwise); writing
        back the value it holds does not. A change that moves where an account
        bound to a route is let in (its subscription domain's route on the
        primary listener, say, when the listen port moves) needs the caller to
        cover that account, as PUT /api/ingress does (403 otherwise).


        The TLS keys are also checked against each other, because a mode with
        nothing behind it silently serves plain HTTP: self_signed is refused
        unless web_cert_id already names an existing panel certificate, files is
        refused unless web_cert_file and web_key_file are already set, and
        neither may be emptied while the mode still uses it. So write a mode's
        inputs first, then the mode, then clear the previous mode's inputs.


        web_masquerade_dir is an absolute directory served as an ordinary web
        site at every address on the panel's port that is not the panel or a
        subscription — what an anonymous visitor or a
        prober sees instead of a 404. Empty answers 404, as before. It answers
        400 for a relative path, `/`, a path that does not exist and one that is
        not a directory: a relative path would resolve against the panel's own
        working directory. Directory listings and dotfiles are never served and
        no symlink out of the directory is followed. It never reaches a request
        the panel accepted, so a 401 on /api/* is still a JSON 401 — and with no
        web_basepath the panel itself answers at the root, leaving nothing for
        the decoy to cover.


        preset_dir is a directory of `*.json` catalogue files the preset
        catalogue (`GET /api/presets`) merges onto the one this build ships, in
        filename order, matching by tag: a new tag is added, an existing one
        replaced. A file that does not parse is skipped with a line on the
        panel's log rather than taking the catalogue with it, and the catalogue
        response names in `sources` the files that were actually read. Relative
        paths resolve against the panel's working directory; empty falls back to
        `./presets`.


        A saved timezone is reported to every session in /api/me right away; the
        server side adopts it at the next restart. Writing web_domain or
        sub_domain reissues the panel's own certificate so it keeps covering
        every one of those names.


        An entry may be a **wildcard**, written `*.sub.example.com` and nothing
        else (`a*.x` and a wildcard at the apex answer 400). It mints a
        different subscription hostname for every subscriber, derived from the
        account and stable across fetches, so one blocked hostname costs one
        customer rather than everybody. It needs a wildcard DNS record, and the
        panel answers exactly one label under it. A plain entry is unaffected.


        sub_domain is a **comma-separated list** of up to 8 domains, stored
        normalised (trimmed, empty and repeated entries dropped), and answers 400
        for an empty entry, a repeat, whitespace inside a name, a URL, or a
        malformed wildcard. Every domain in it serves /sub, and on those names the
        panel itself is reachable only at web_basepath — with no base path set
        they serve subscriptions and nothing else at all, which is also the one
        way this setting locks an operator out (the panel then answers on no
        listed name, and `nx-panel config set sub_domain ""` is the way
        back). The **first** is the one generated subscription links are
        published on, so a blocked domain is replaced by adding its successor and
        moving it to the front, while links already handed out keep working on
        the domain that issued them until it is removed. A single value — every
        install that predates the list — behaves exactly as before.
        Every key here is also writable offline with `nx-panel config set`,
        which is the way back in when one of them makes the panel unreachable.
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: { value: { type: string } }, required: [value] } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Setting" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { description: "A sudo-only key from a non-sudo admin, a secret key that is never writable here, or a change to an address key without ingress:write", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409":
          description: "An address key: under /api/v1 always (`moved: ingress`), under /api a change the ingress can no longer describe"
          content: { application/json: { schema: { type: object, properties: { error: { type: string }, moved: { type: string } } } } }
        "428": { description: The second factor must be presented again (POST /api/me/confirm), content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api/scopes:
    get:
      tags: [admins]
      summary: The scope vocabulary, and the scope each route demands
      description: >
        Read this instead of hardcoding the permission model — both halves come
        from the tables the server enforces with. `scopes` is every scope a
        token may be granted; `routes` maps each authenticated route pattern
        ("GET /api/users") to the scope it requires, where "" means any token
        reaches it and "-" means no token does. `GET /api/me` reports which
        scopes the calling token actually holds.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  scopes: { type: array, items: { type: string } }
                  routes:
                    type: object
                    additionalProperties: { type: string }
                    description: 'Route pattern → required scope ("" = any token, "-" = no token).'

  /api/tokens:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [admins]
      summary: List third-party API tokens (sudo only)
      description: Only a hash is stored, so `token` is never present in listings.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/ApiToken" } } } } }
        "403": { $ref: "#/components/responses/Error" }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Create a third-party API token (sudo only)
      description: >
        The token authenticates on this API via `Authorization: Bearer`. The
        response is the only time the plaintext token is revealed — the server
        stores a SHA-256 of it.


        `adminId` is the operator the token acts as; it defaults to the caller,
        and deleting that operator deletes the token. A token never holds more
        than its caller does: 403 when what it would reach is not covered by the
        caller's scopes — its scopes, or the owner's whole role when none are
        given or when the owner is another account (a token is also its owner's
        identity).
        `role` defaults to the
        owner's role and is rejected if it outranks it. `scopes` narrows which
        routes the token may reach (empty = every route its role allows, the
        behaviour of tokens issued before scopes existed); a `:write` scope
        implies the matching `:read`. `rateLimit` is requests per minute,
        0 for the panel default.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                desc: { type: string }
                role: { $ref: "#/components/schemas/AdminRole" }
                adminId: { type: integer, description: "Operator the token acts as; defaults to the caller" }
                scopes: { type: array, items: { type: string }, description: "See GET /api/scopes; empty = unrestricted within the role" }
                addon: { type: string, description: "Addon id (a slug: a-z, 0-9, _ and -, up to 32 characters). It is what a `kind: addon` webhook is bound to; a token without one is not an addon's." }
                rateLimit: { type: integer, description: "Requests per minute, 0 = panel default" }
                expiry: { type: integer, format: int64, description: "Unix seconds, 0 = never" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ApiToken" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
  /api/tokens/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [admins]
      summary: Delete a third-party API token (sudo only)
      description: The token stops authenticating immediately. Refused with 409 naming the route while an `api` route of the ingress admits it — or its addon slug, when no other token carries that slug — also in the table a change waiting for its confirmation would put back; take it off the route first. A token an addon holds (`managedBy`) is 409; it goes with the addon.
      responses:
        "200": { description: Deleted }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }

  /api/changes:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { name: actor, in: query, schema: { type: string }, description: "Exact operator username" }
        - { name: key, in: query, schema: { type: string }, description: "Exact entity type (user, node, admin, …)" }
        - { name: action, in: query, schema: { type: string }, description: "Exact action (create, update, delete, …)" }
        - { name: from, in: query, schema: { type: integer, format: int64 }, description: "Unix seconds, inclusive lower bound" }
        - { name: to, in: query, schema: { type: integer, format: int64 }, description: "Unix seconds, inclusive upper bound" }
      tags: [admins]
      summary: Read the admin audit log (sudo only)
      description: >
        Newest first. Records exist only for actions taken while the
        `changes_logging` setting was on; the log is empty otherwise.


        Secret-looking fields in `obj` (passwords, private keys, UUIDs, tokens)
        are replaced with `[redacted]` — both when the record is written and
        again when it is read, so records written by older panels are covered
        too. `changes_retention_days` bounds how far back the log goes.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Change" } } } } }
        "403": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { name: before, in: query, schema: { type: integer, format: int64 }, description: "Unix seconds; omit to delete the whole log" }
      tags: [admins]
      summary: Purge audit records (sudo only)
      description: The purge is itself recorded, so an emptied log still says who emptied it.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, properties: { deleted: { type: integer, format: int64 } } } } } }
        "403": { $ref: "#/components/responses/Error" }
  /api/changes/facets:
    get:
      tags: [admins]
      summary: Distinct actors, entity types and actions in the audit log (sudo only)
      description: The filter vocabulary actually present in the log, for building a filter UI.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  actors: { type: array, items: { type: string } }
                  keys: { type: array, items: { type: string } }
                  actions: { type: array, items: { type: string } }
        "403": { $ref: "#/components/responses/Error" }

  /api/license:
    get:
      tags: [meta]
      summary: The licence, this host's hardware id, usage against the caps, and the free tier
      description: >
        The licence's status (limits, expiry, whether it is valid here and
        when it was last verified), the hardware id a licence is issued to,
        each capped resource's use against its limit (`usage`), the free
        tier's caps (`free`), and a purchase in progress (`checkout`). Main
        admins only.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/LicenseView" } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [meta]
      summary: Install a licence token
      description: >
        Validates and stores a pasted token. One that is malformed, expired or
        bound to another machine is `400` with the reason, rather than a
        silent fall back to the free tier. Main admins only, and confirmed
        with the second factor when the account has one (`428`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/LicenseView" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "428": { description: The second factor must be presented again (POST /api/me/confirm), content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /api/license/checkout:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [meta]
      summary: Open a purchase with the licence service
      description: >
        Buy, renew, upgrade or move: opens a checkout session and answers the
        licence view with its address in `checkout` for the browser to open.
        The token it pays for arrives on the heartbeat by itself. `502` when
        the service cannot be reached or refuses, with its reason.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/LicenseView" } } } }
        "502": { $ref: "#/components/responses/Error" }
  /api/license/recheck:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [meta]
      summary: Check the licence with the licence service now
      description: >
        Runs the heartbeat at once rather than waiting for its interval, so a
        renewal or a revocation applies without a restart. The answer is the
        licence view plus `recheck`: `verified`, `unverifiable`,
        `unreachable` or `none`.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/LicenseView" } } } }
  /api/panel/info:
    get:
      tags: [settings]
      summary: Panel host + runtime metrics (the panel's own counterpart of /api/nodes/{id}/info)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PanelInfo" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "500": { description: "The backup readout could not be read (sudo only); reporting it as \"off, never backed up\" would be the one answer an operator reads as deliberate.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /api/panel/update:
    get:
      tags: [settings]
      summary: The release check, the install method and the last (or running) upgrade (sudo only)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PanelUpdate" } } } }
        "403": { $ref: "#/components/responses/Error" }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Upgrade the panel to the newest release (sudo only)
      description: >
        Takes a backup, downloads and verifies the release, writes the new
        binary beside the old one, swaps and exits; systemd restarts the panel
        on the new build. Answers 202 immediately — follow it with GET
        /api/panel/update, which keeps answering after the restart because the
        record and the log are files.


        Refused with 409 and the reason when this install cannot replace itself
        — a container cannot replace its own image — and when it is already on
        the newest release. Migrations are forward-only, so a panel that fails
        to come back is rolled back to the previous *binary* and the database
        stays where the new build left it; the backup taken in the first step
        is the way back from the schema.
      responses:
        "202": { description: Started, content: { application/json: { schema: { $ref: "#/components/schemas/PanelUpdate" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "428": { $ref: "#/components/responses/Error" }
  /api/panel/update/check:
    post:
      tags: [settings]
      summary: Ask the release stream now (sudo only)
      description: >
        Allowed while the daily check is switched off: that setting governs what
        the panel does on its own, not what an operator asks for.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/PanelUpdate" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/panel/update/node-cache:
    post:
      tags: [settings]
      summary: Download the newest node release into this panel's cache (sudo only)
      description: >
        Separate from upgrading the panel. Asks the node repository for its
        newest release itself, then downloads and verifies the archive for each
        architecture the fleet runs on (amd64 and arm64 always) and replaces
        the cached binary and its version label. An architecture already at
        that release is kept, and one the release does not publish is reported
        in `failed` while the others still land. Answers 502 when nothing could
        be cached. The cache is what a node install uploads; without a current
        one the install downloads from GitHub instead.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { $ref: "#/components/schemas/PanelUpdateStatus" }
                  cached: { type: array, items: { type: string } }
                  kept: { type: array, items: { type: string } }
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        arch: { type: string }
                        reason: { type: string }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/restart:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Restart the panel (sudo only)
      description: >
        Gracefully shuts the panel down right after responding; the process
        manager (systemd/compose) starts it again with the new web settings.
        Sessions survive the restart. The answer says where this process
        answers (`current`) and where the next one will (`next`), from the saved
        web settings: a pinned listen address keeps its port, and a certificate
        that does not load means plain HTTP. A host that refuses the new listen
        address is the one case it cannot foresee; the panel then falls back to
        its configured address.
      responses:
        "200":
          description: Restarting
          content:
            application/json:
              schema:
                type: object
                required: [status, current, next]
                properties:
                  status: { type: string, enum: [restarting] }
                  current: { $ref: "#/components/schemas/PanelAddress" }
                  next: { $ref: "#/components/schemas/PanelAddress" }
        "403": { $ref: "#/components/responses/Error" }
        "501": { $ref: "#/components/responses/Error" }

  /api/setup/backup/preview:
    post:
      tags: [setup]
      summary: Validate a backup archive during first-run setup
      description: >
        The same inspection as `POST /api/backup/preview`, minus the
        node-identity comparisons (`mtls_changed`, `node_pin_changed`): an
        unconfigured panel has no nodes to compare the archive against — and
        minus `unusable_kept_address`, since this restore never keeps the
        install's address. It is
        reachable while the panel has no account to authenticate against —
        which is exactly the state a server migration starts in.


        Authorised by the setup token in `t`. Without it the request never
        reaches the handler: the same gate that closes every route on an
        unconfigured panel answers 404. The handler compares the token again
        in constant time (403) and answers 409 once setup is complete.
      parameters:
        - { name: t, in: query, required: true, schema: { type: string }, description: The setup token the installer printed. }
        - { name: X-Backup-Passphrase, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/gzip:
            schema: { type: string, format: binary }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/BackupPreview" } } } }
        "400": { description: Not a readable archive, content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "404": { description: "Missing or wrong `t` while the panel is unconfigured: the setup gate closes the route before the handler sees the request. (The 403 below is the handler's own second comparison, which the gate normally reaches first.)" }
        "409": { description: Setup is complete; this route is closed and the authenticated one takes over }
        "413": { description: Larger than 1 GiB (code `too_large`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "500": { $ref: "#/components/responses/Error" }
  /api/setup/backup/restore:
    post:
      tags: [setup]
      summary: Restore a backup onto an unconfigured panel
      description: >
        Moving a panel to a new server means a fresh install with no admin —
        the state that closes every authenticated route — so a restore
        reachable only after logging in would be unreachable exactly when it is
        wanted. This is that route, held to the same rule as the wizard itself:
        nothing without the setup token.


        The two guards the authenticated restore defaults to are deliberately
        off here. Both preserve what *this* install already decided, and this
        install has decided nothing: keeping its address settings would write
        empty rows over the archive's real ones, and keeping its licence would
        keep no licence at all. The archive is the configuration.


        The address that comes back is therefore the old server's. That is
        recoverable by design: `-listen` / `NX_WEB_LISTEN` pin the bind
        address over any setting, and the panel falls back to the address it
        was started with when the configured one refuses to bind.
      parameters:
        - { name: t, in: query, required: true, schema: { type: string }, description: The setup token the installer printed. }
        - { name: confirm, in: query, required: true, schema: { type: string, enum: [replace-database] } }
        - { name: X-Backup-Passphrase, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/gzip:
            schema: { type: string, format: binary }
      responses:
        "200": { description: Restored; the panel is restarting, content: { application/json: { schema: { $ref: "#/components/schemas/BackupRestore" } } } }
        "400": { description: "Refused: `confirm`, `version_mismatch`, `no_admins`, `unusable_ingress`, `encrypted`, `passphrase` or `format`", content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "404": { description: "Missing or wrong `t` while the panel is unconfigured: the setup gate closes the route before the handler sees the request. (The 403 below is the handler's own second comparison, which the gate normally reaches first.)" }
        "409": { description: "Setup is complete (plain `Error`), or a restore is already in progress (code `busy`, a `BackupError`)", content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "413": { description: Larger than 1 GiB (code `too_large`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "500": { $ref: "#/components/responses/Error" }
        "501": { description: This database or runtime cannot be restored in place (code `unsupported`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }

  /api/ingress:
    get:
      tags: [settings]
      summary: The panel's listeners, routes and fallbacks (sudo)
      description: >
        Every address the panel listens on and what each host and path serves.
        A request is matched on the listener it arrived on: routes are tried in
        order, and the first whose hosts and prefix match and whose service
        takes the path (`sub`: /sub only; `panel`: everything else, and /sub,
        /api/v1, /metrics and the node installer's two paths by its `panel`
        options; `api`: /api/v1 only; `metrics`: /metrics; `node_install`:
        the installer's paths; `static`, `redirect`, `canned`, `upstream`:
        every path)
        answers, with its prefix stripped. A route with `allowIps` answers only
        those client addresses, every other one getting its fallback. A request
        no route takes is answered by the matched route's fallback, then the
        listener's, then a 404. An `api` route takes API tokens only: a cookie
        never counts there, a login presented as a bearer token is refused
        (401), only routes that authenticate answer (the login and setup
        routes are 404 there), and a token its `api` options do not name is
        refused (403). An `upstream` route is a reverse proxy (WebSocket
        included) to a URL or a registered addon: the panel's session and
        sign-on cookies are cut out of every request and every Set-Cookie
        naming them is dropped, X-Forwarded-For / -Host / -Proto are the
        panel's own, a suspended addon's route answers its fallback, and an
        instance-metadata address is refused at every dial. A listener's
        `passthrough` rows hand a TLS connection whose handshake names their
        `sni` to `target` untouched, before anything else runs; the target
        sees the panel's address as the client. A panel or api route with
        `adminId` is that account's: only it signs in there (password, second
        factor, single sign-on) or uses its tokens there, and an account with
        such a route signs in and uses its tokens nowhere else — the panel
        owner excepted from the second rule, never the first. A refused sign-in
        is answered as a wrong password and not counted against the address;
        a refused session is 401 and a refused token 403, on every request. A
        panel route's `panel.tiers` keeps the other tiers from signing in (the
        owner never). A canonical sub route with `adminId` is where that
        account's customers' links are published, and with `sub.ownedOnly` it
        answers only their subscriptions, every other token (and an unknown
        one) getting its fallback. Rows refer to each other by name. `shaped` says whether the old
        address settings (PUT /api/settings/web_domain and the rest) can still
        describe and edit the table. Each listener carries `state`, how the
        running process brought it up.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Ingress" } } } }
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Replace the panel's listeners, routes and fallbacks (sudo)
      description: >
        The whole table, in the GET shape (`shaped`, `state` and `pending` are
        ignored). A row keeps its id when it carries the id, or else the name
        it had; a renamed row without its id is a new one. Refused with 400
        when a rule fails (exactly one primary listener, on and carrying a
        panel route; the primary listener's `main` routes kept, with their
        service, unbound and on it; an address of a listener not already the
        panel's own that is in use on the server; names that tell the rows of
        a kind apart; no two listeners on one socket; a TLS mode with what it
        needs; certificates,
        decoy directories, API tokens, addons, accounts and brandings that
        exist; hosts with no port; a service's options only on that service)
        and when, afterwards, the request making the change would no longer
        reach the panel on the listener, host and prefix it came in on (its
        page, and its API there, which the confirmation is sent to) — or, for
        a request through an `api` route, that route would no longer take it
        — or its own account could no longer sign in there. A change that
        moves where an account bound to a route (panel, api or subscription)
        is let in, when the caller does not cover that account, is 403:
        binding, moving or removing a bound route (its account, service,
        hosts, prefix, listeners, allowed addresses or options); switching
        off, moving or changing the TLS of a listener it is on; adding,
        removing or changing a passthrough row there whose SNI overlaps its
        hosts; or adding, removing, reordering or changing what it takes of a
        route ahead of it on a shared listener whose hosts and prefix overlap
        its own. An edit that leaves all of that as it was — the document
        sent back unchanged, a route on other hosts or listeners — is not.

        A change is applied at once and live — no restart: listeners are
        bound and let go, certificates and routes swapped, open connections
        kept — and is pending: unless POST /api/ingress/confirm receives the
        `pending.token` of the answer before `pending.confirmBy` (120 s), the
        ingress before it is put back by itself, and a panel that stops with a
        change pending puts it back as it starts. While one waits another is
        409. A write through the old address settings, an account's
        subscription domain or `nx-panel ingress reset` supersedes a
        pending change rather than being undone by it. For an account with
        two-factor authentication on it answers 428 until the second factor
        is presented again.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Ingress" }
      responses:
        "200": { description: The table as saved, content: { application/json: { schema: { $ref: "#/components/schemas/Ingress" } } } }
        "400": { description: A rule failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: Where an account bound to a route is let in moves and the caller does not cover that account, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: Another change waits for its confirmation, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "428": { description: The second factor must be presented again (POST /api/me/confirm), content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api/ingress/confirm:
    post:
      tags: [settings]
      summary: Confirm a change to the ingress (public, by its token)
      description: >
        Sent from wherever the change left the panel — the same address
        unless the change moved it — with the token the change was answered
        with. Public, since the new address may be a host the caller has no
        session on yet: the token (256 random bits, good once, for one
        change, until its window closes) is the authority. 404 for a token
        that confirms nothing, a change already put back among them; 409
        with `applying: true` and Retry-After while the running panel is
        still applying the change, which is asked again.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string } } }
      responses:
        "200": { description: Confirmed, content: { application/json: { schema: { type: object, properties: { revision: { type: integer }, state: { type: string } } } } } }
        "404": { description: Nothing is waiting for that token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409":
          description: The change is still being applied; ask again after Retry-After
          headers: { Retry-After: { schema: { type: integer } } }
          content: { application/json: { schema: { type: object, properties: { error: { type: string }, applying: { type: boolean } } } } }

  /api/ingress/test:
    post:
      tags: [settings]
      summary: What a request would get from the ingress, and why (sudo)
      description: >
        The route test: the same walk that serves a request, answered with
        what it did — a passthrough and its target, the route that answers and
        the path its service sees, a prefix redirect, or a refusal and the
        fallback answering it — and every route it looked at with what it made
        of it (other-listener, other-host, excluded-host, other-prefix,
        not-taken, refused-ip, redirect, taken). A listener switched off, and
        a TLS handshake naming none of the certificates of a listener that
        refuses unknown names, are refusals no route took part in, with
        `closed` saying which. With `ingress` it tries that document instead
        of the saved one: the page tests a draft before saving it. An empty
        host or path is tried as sent (an empty path is `/`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [listener]
              properties:
                listener: { type: string, description: "The listener's name" }
                host: { type: string }
                path: { type: string }
                sni: { type: string }
                tls: { type: boolean }
                ip: { type: string, description: "The client's address, as the listener's trusted proxies resolve it" }
                ingress: { $ref: "#/components/schemas/Ingress" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  outcome: { type: string, enum: [passthrough, route, redirect, refused] }
                  route: { type: string }
                  service: { type: string }
                  path: { type: string }
                  redirect: { type: string }
                  fallback: { type: string }
                  target: { type: string }
                  strictSni: { type: boolean }
                  closed: { type: string, enum: [listener-off, unknown-sni], description: "On a refusal no route took part in, why" }
                  steps:
                    type: array
                    items: { type: object, properties: { route: { type: string }, outcome: { type: string } } }
        "400": { $ref: "#/components/responses/Error" }

  /api/ingress/revisions:
    get:
      tags: [settings]
      summary: The ingress's history (sudo)
      description: >
        Every revision, newest first, the last fifty: who made it (`actor`),
        how (`source`: api, restore-revision, settings, account, reset,
        restore, migration, replace) and what became of it (`state`: applied,
        pending — with `confirmBy` — reverted, superseded).
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/IngressRevision" } } } } }

  /api/ingress/revisions/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    get:
      tags: [settings]
      summary: One revision and the ingress it left (sudo)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/IngressRevision" } } } }
        "404": { $ref: "#/components/responses/Error" }

  /api/ingress/revisions/{id}/restore:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Put an earlier ingress back (sudo)
      description: >
        The revision's table, proposed as a change like PUT /api/ingress: the
        same rules, applied live and pending, put back unless confirmed with
        the answer's `pending.token`.
      responses:
        "200": { description: The ingress as saved, pending, content: { application/json: { schema: { $ref: "#/components/schemas/Ingress" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "428": { $ref: "#/components/responses/Error" }

  /api/brandings:
    get:
      tags: [settings]
      summary: Every branding (sudo)
      description: >
        A branding is a look for the admin panel (`panel`: product name, logo,
        favicon, default palette and density, a login message) and for the
        subscription page and its headers (`sub`: theme, title, support link,
        announcement, the page pointer). A route carries one (`brandingId` on
        GET /api/ingress) and so does an account (`brandingId` on
        /api/admins). The admin panel takes its route's, else its bound
        account's, else the built-in look; a subscription takes, field by
        field, the user's owner's, then its route's, then the sub_* settings.
        Each row lists the routes and accounts carrying it.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Branding" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Create a branding (sudo)
      description: >
        400 when a rule fails: a name of at most 64 characters, the logo and
        favicon base64 data URIs of an image (png, jpeg, webp, gif, svg, ico),
        at most 256 KB together, a palette and density the panel has, a theme
        name and a support link the sub_* settings would accept. 409 for a
        name another branding has.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Branding" }
      responses:
        "200": { description: The branding as saved, content: { application/json: { schema: { $ref: "#/components/schemas/Branding" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/brandings/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Replace a branding (sudo)
      description: The whole branding, by the POST rules.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Branding" }
      responses:
        "200": { description: The branding as saved, content: { application/json: { schema: { $ref: "#/components/schemas/Branding" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Delete a branding (sudo)
      description: The routes and accounts carrying it fall back to what comes next.
      responses:
        "200": { description: Deleted }

  /api/branding:
    get:
      tags: [auth]
      summary: The admin panel's look on this address (public)
      description: >
        The panel half of the branding the route this request came in on
        carries, or its bound account's, with the images as paths under the
        panel's prefix; every field empty is the built-in look. Public, so the
        login page is drawn in it. index.html carries the same object in its
        `nx-brand` meta.
      security: []
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/BrandingView" } } } }

  /api/branding/logo:
    get:
      tags: [auth]
      summary: The logo of the address's branding (public)
      security: []
      responses:
        "200": { description: The image, content: { image/*: { schema: { type: string, format: binary } } } }
        "404": { description: The address's branding has none }

  /api/branding/favicon:
    get:
      tags: [auth]
      summary: The favicon of the address's branding (public)
      security: []
      responses:
        "200": { description: The image, content: { image/*: { schema: { type: string, format: binary } } } }
        "404": { description: The address's branding has none }

  /api/bans:
    get:
      tags: [security]
      summary: The login ban list (sudo)
      description: >
        Every ban in force, newest first. Two sources: `auto` — written the
        moment the login limiter locks an address out, so the lockout survives
        a restart, and escalating with each repetition inside a week (10
        minutes, an hour, a day, a week) — and `manual`. A ban refuses the
        routes reachable without a session (login, its second step, the setup
        submission) with 403 and nothing else. Addresses in the `login_allowlist`
        setting are never locked out or banned. Whether an address is the
        client's or the proxy's is decided by the `trusted_proxies` setting;
        without it, behind a proxy, every visitor is one address.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/IPBan" } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [security]
      summary: Ban an address or a prefix (sudo)
      description: Refused (400) when the prefix contains the address the request itself comes from.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prefix]
              properties:
                prefix: { type: string, description: "An IP address or a CIDR" }
                reason: { type: string }
                hours: { type: integer, description: "Lifetime in hours; 0 or absent = permanent" }
      responses:
        "200":
          description: Banned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IPBan" }
        "400": { $ref: "#/components/responses/Error" }
  /api/bans/{id}:
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { name: id, in: path, required: true, schema: { type: integer } }
      tags: [security]
      summary: Lift a ban (sudo)
      responses:
        "200": { description: Lifted }
        "404": { $ref: "#/components/responses/Error" }
  /api/events:
    get:
      tags: [webhooks]
      summary: The event catalog
      description: >
        Every event the panel raises, with its family (the part before the
        dot). A subscriber's `events` filter is checked against this list.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/EventInfo" } }
  /api/addons:
    get:
      tags: [addons]
      summary: The registered addons (sudo; no token) — a main admin holding every permission
      description: >
        Every addon the panel works with — separate software it reaches over
        the network only — with its address, entry link, health path and its
        two grants: the API token and the event subscription it holds. A
        signed addon's grants are what its manifest declares; the operator's
        own addon's are chosen on this page. Either is locked on the tokens
        and webhooks pages. Every addon route is closed to API tokens: an
        addon registering addons would be a token minting tokens.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Addon" } } } } }
        "403": { $ref: "#/components/responses/Error" }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Register the operator's own addon by hand (sudo; no token) — a main admin holding every permission
      description: >
        The form for an addon without a signed manifest. `token` present
        creates an API token (bound to the panel owner, carrying the addon
        id; empty `scopes` = unrestricted within the owner's role); `webhook`
        present creates an addon subscription (empty `events` = every event).
        The response carries `credentials` — the token and the signing
        secret — once. Plain http is refused for an address that is not on
        this host or a private network.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AddonManual" }
      responses:
        "200": { description: Registered; `credentials` is in this response only, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /api/addons/preview:
    post:
      tags: [addons]
      summary: Read and verify a signed addon's manifest (sudo; no token) — a main admin holding every permission
      description: >
        Fetches `/.well-known/nx-addon.json` under the address, checks
        its shape, its `api` version (0 and 1) and its signature against the
        keys the panel trusts — NX's, and the developer keys the addon
        directory vouches for on that slug — and answers what the consent
        screen shows, with the addon's directory tier. Nothing is created. 502 when the addon does
        not answer or its manifest is refused; 409 when an addon with its id
        is already registered.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [url], properties: { url: { type: string } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AddonConsent" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/addons/directory:
    get:
      tags: [addons]
      summary: The addon directory as this panel last read it (sudo; no token) — a main admin holding every permission
      description: >
        The signed index of addons.nx-panel.org, or of the mirror named by
        the `addon_directory_url` setting, read once a day beside the release
        check (and on the same `update_check` switch) and refused unless the
        directory key signed it. Each listing comes with its manifest read the
        way registration reads one, whether this panel meets its
        `requires.panel`, who signed it as this panel sees it, and — for an
        addon registered here — whether the directory lists a newer version.
        `updates` counts those. The developer keys a listing vouches for are
        trusted for that slug when a manifest is verified.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AddonDirectory" } } } }
  /api/addons/directory/check:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Read the addon directory now (sudo; no token) — a main admin holding every permission
      description: >
        Allowed while the daily read is off. 502 when the directory does not
        answer, its index is not signed by the directory key (a mirror that
        altered it), or it is older than the one held; the index held stays
        either way, and the reason is the view's `error`.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AddonDirectory" } } } }
        "502": { $ref: "#/components/responses/Error" }
  /api/addons/directory/{slug}:
    parameters: [ { name: slug, in: path, required: true, schema: { type: string } } ]
    get:
      tags: [addons]
      summary: How a listed addon is installed (sudo; no token) — a main admin holding every permission
      description: >
        The install wizard's source: the listing, the questions its manifest
        asks (with their labels in every language), the ways it can be
        installed by a command (`script` and `docker` through its install.sh,
        `compose` when it ships only a compose file; none before a release),
        the panel address the addon is told to call, and whether it will
        register by the claim code the panel issues (`claim`) or be added by
        hand (`manual`, no signature this panel trusts).
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AddonInstallPlan" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/addons/installs:
    get:
      tags: [addons]
      summary: Installs waiting for their addon to answer (sudo; no token) — a main admin holding every permission
      description: Expired ones (a week) are dropped. The claim code is never read back.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/AddonInstall" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Start installing a listed addon by hand (sudo; no token) — a main admin holding every permission
      description: >
        Checks the answers as the addon kit does (an option hidden by its
        `when` is skipped, a blank one takes its default, a required one with
        neither is refused, every value must be of its type and on one line),
        issues a claim code, and returns the command to run on the addon's
        host — the install script with `--opt` flags, or a compose file and
        its `.env` — carrying the answers, the panel address and the code. The
        command is shown once: a secret answer is in it and nowhere else. One
        install waits per addon; starting another replaces it. 409 when the
        addon is already registered, needs a newer panel, or has nothing to
        install from.


        When the manifest asks a `path` option, the addon's base path is
        appended to baseUrl and the addon is registered at that address: the
        answer, "/" for the root asked for on purpose, or — when the option
        is shown and left blank — twelve random lowercase letters and digits,
        passed to the addon with the other answers and shown in the install's
        baseUrl. A baseUrl that already carries a path
        must carry the same one; another path answers 400.


        With `ssh` the panel installs it on that host itself and answers the
        job instead of a command: official and verified addons only (403
        otherwise), as a service or with Docker (400 otherwise); with
        `ssh.local`, on the panel's own server with no SSH (403 when the panel
        cannot: in a container, or not as root). The compose method writes
        the answers and nothing else to its .env, so nothing publishes port
        80 for an addon's acme-http (the form does not offer it there). Running a
        script as root on a host is a confirmed route: a session with
        two-factor authentication whose second factor is older than ten
        minutes answers 428 until it is presented again (POST
        /api/me/confirm). The job fails before anything runs on the host when
        the release's manifest at its tag is not the one the directory lists,
        and, for a service install, when the release publishes no SHA256SUMS,
        does not list the binary, or the binary does not match it. A
        certificate chosen for it is confirmed alike: its claim code fetches
        the certificate's key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, baseUrl]
              properties:
                slug: { type: string }
                method: { type: string, enum: [script, docker, compose], description: "Empty = the first the plan offers" }
                baseUrl: { type: string, description: "Where the panel will reach the addon once it runs (plain http only on a private address)" }
                panelUrl: { type: string, description: "Where the addon reaches the panel; empty = the panel as this request reached it" }
                answers: { type: object, additionalProperties: true, description: "By option key, a JSON value of the option's type" }
                ssh: { $ref: "#/components/schemas/AddonSSH" }
                certificateId: { type: integer, nullable: true, description: "A panel certificate the addon fetches by its claim code, then by its token, and serves. For an addon not on this server only one issued by dns-01, a self-signed or an uploaded one, and never the panel's own HTTPS certificate (400 otherwise)" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AddonInstallCreated" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "428": { $ref: "#/components/responses/Error" }
  /api/addons/jobs/{job}:
    parameters: [ { name: job, in: path, required: true, schema: { type: string } } ]
    get:
      tags: [addons]
      summary: An addon install, update or removal over SSH, and its log after cursor (sudo; no token)
      parameters:
        - { name: cursor, in: query, schema: { type: integer }, description: "Lines already read; the answer's cursor is the next one" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/AddonJob" }
                  lines: { type: array, items: { type: string } }
                  cursor: { type: integer }
        "404": { $ref: "#/components/responses/Error" }
  /api/addons/jobs/{job}/cancel:
    parameters: [ { name: job, in: path, required: true, schema: { type: string } } ]
    post:
      tags: [addons]
      summary: Stop a running addon job (sudo; no token) — what it already did on the host stays done
      responses:
        "200": { description: Cancelling }
        "404": { $ref: "#/components/responses/Error" }
  /api/addons/hosts:
    get:
      tags: [addons]
      summary: Hosts the panel installed an addon on over SSH (sudo; no token)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/AddonHost" } } } } }
  /api/addons/hosts/{slug}/update:
    parameters: [ { name: slug, in: path, required: true, schema: { type: string } } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Update an addon on its host to the directory's latest release (sudo; no token)
      description: >
        Runs the release's install.sh again in place (its configuration and
        data are kept), then reads the addon's new manifest at once — applied,
        or held for approval when it asks for more. Credentials may be left
        out when the panel's key is authorised on the host. A confirmed
        route: 428 to a session whose second factor is older than ten
        minutes. The job fails before anything runs on the host when the
        release's manifest at its tag is not the one the directory lists (the
        tag was moved since the directory read it), and, for a service
        install, when the release publishes no SHA256SUMS, does not list the
        binary, or the binary does not match it. An addon installed on the
        panel's own server is updated there with nothing asked; one installed
        over SSH on this very machine may move to that (`local`, when the
        host's `mayMoveLocal`). 409 when the panel cannot run it here (in a
        container, or not as root) or the host is not this server.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AddonSSH" }
      responses:
        "200": { description: The job, content: { application/json: { schema: { $ref: "#/components/schemas/AddonJob" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "428": { $ref: "#/components/responses/Error" }
  /api/addons/hosts/{slug}/uninstall:
    parameters: [ { name: slug, in: path, required: true, schema: { type: string } } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Remove an addon from its host (sudo; no token)
      description: Runs its install.sh — from the release it was installed at — with --uninstall, and --purge to delete its data too, then takes the panel's key out of the login's authorized_keys when the panel authorised it there and no other addon or node on that machine (by any of its names or addresses) at that login uses it, or might — a lookup that fails keeps the key. A confirmed route (428 to a stale second factor). The job fails before anything runs when the manifest at that release is not the listed one (for the listed release) or not signed by a key trusted for the addon (for an older one). Its registration stays until removed on the Addons page. One installed on the panel's own server is removed there with nothing asked (409 when the panel cannot run it here).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/AddonSSH" }
                - { type: object, properties: { purge: { type: boolean } } }
      responses:
        "200": { description: The job, content: { application/json: { schema: { $ref: "#/components/schemas/AddonJob" } } } }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "428": { $ref: "#/components/responses/Error" }
  /api/addons/installs/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    delete:
      tags: [addons]
      summary: Cancel a waiting install (sudo; no token) — its claim code stops being honoured
      responses:
        "204": { description: Cancelled }
        "404": { $ref: "#/components/responses/Error" }
  /api/addons/installs/{id}/check:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Whether the installed addon answers yet (sudo; no token) — a main admin holding every permission
      description: >
        Reads the manifest at the install's address (signed, and the same
        addon) and asks its health path. Ready carries the consent screen.
        404 when the install expired or was cancelled.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AddonInstallCheck" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/addons/installs/{id}/register:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Register an installed addon with the code the panel issued (sudo; no token) — a main admin holding every permission
      description: >
        The claim-code registration of POST /api/addons/register, with the
        address and the code the install holds; `digest` is the reviewed
        manifest's, from the check's consent. 409 for an addon with no
        signature this panel trusts (it is added by hand).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [digest]
              properties:
                digest: { type: string }
                certSha256: { type: string, description: "The consent's certSha256, when it named one: the addon's own certificate, trusted with the approval" }
      responses:
        "200": { description: Registered, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/addons/register:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Register a signed addon by its claim code (sudo; no token) — a main admin holding every permission
      description: >
        The manifest is fetched again and must hash to `digest` (from the
        preview the main admin approved; 409 otherwise). The grants it
        declares are created and POSTed to its `setup` path with the claim
        code; an addon that does not accept them (a wrong code) leaves
        nothing behind, and its answer is the 502's message.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, claimCode, digest]
              properties:
                url: { type: string }
                claimCode: { type: string }
                digest: { type: string }
                certSha256: { type: string, description: "The preview's certSha256, when it named one: the addon's own certificate, trusted with the approval" }
      responses:
        "200": { description: Registered, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/addons/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Edit the operator's own addon (sudo; no token) — a main admin holding every permission
      description: >
        The hand-made form again. A grant switched on is created (its secret
        in `credentials`, once), one switched off is deleted, one changed is
        edited in place. The addon id cannot change. A signed addon is 409:
        its grants are what its manifest declares. An address on another
        host drops the certificate of its own trusted at the old one
        (pinnedCertSha256), judges `local` again by the new address, and is
        400 when the certificate chosen for it does not fit there. Switching
        the token off is 409 while an `api` route admits the token or the
        addon's slug.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AddonManual" }
      responses:
        "200": { description: Updated, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Remove an addon (sudo; no token) — a main admin holding every permission
      description: >
        The addon's webhook is sent `panel.addon_removed` first, whatever its
        filter; then its token, its subscription and its row are deleted. The
        panel keeps nothing else of an addon. Refused while an `upstream`
        route of the ingress proxies to the addon, or an `api` route admits
        its token or its slug — or a route of the address a change waiting for
        its confirmation would put back does — with a 409 naming the route,
        before the addon is told anything.
      responses:
        "200": { description: Removed }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /api/addons/{id}/entry:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Set the link to an addon's interface (sudo; no token) — a main admin holding every permission
      description: >
        The address the panel reaches an addon at is often one no browser
        can open (a Docker name, a private address), so the main admin gives
        the link. A path is joined to the addon's address; empty goes back
        to the manifest's `ui` for a signed addon. For a signed addon it
        overrides the manifest and survives its updates.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [entryUrl], properties: { entryUrl: { type: string } } }
      responses:
        "200": { description: Set, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/addons/{id}/certificate:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Choose the certificate an addon fetches from the panel (sudo; no token) — a main admin holding every permission
      description: >
        The addon fetches it with its token (GET /api/addons/self/certificate)
        and serves its public address with it, so it needs no ACME of its own.
        For an addon not on this server only a certificate issued by dns-01,
        a self-signed or an uploaded one fits: http-01 and tls-alpn-01 prove
        the panel's server, and the panel's own HTTPS certificate stays on
        this server. null clears it. A confirmed route: the addon fetches the
        certificate's key (428 for a second factor older than ten minutes).
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [certificateId], properties: { certificateId: { type: integer, nullable: true } } }
      responses:
        "200": { description: Set, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "428": { $ref: "#/components/responses/Error" }
  /api/addons/{id}/certificate/served:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [addons]
      summary: The certificate an addon serves, and whether the panel trusts it (sudo; no token) — a main admin holding every permission
      description: >
        Only the TLS handshake at the addon's address is tried (any answer
        will do; an addon added by hand may serve no signed manifest), and
        local says whether it runs on the panel's own server. Trusted when a CA, a
        certificate from the panel or one the main admin pinned vouches for
        it; otherwise sha256 names the certificate it serves now — after it
        renewed one it made itself, say — to trust with PUT …/certificate/pin.
      responses:
        "200":
          description: What it serves
          content:
            application/json:
              schema:
                type: object
                required: [trusted, pinned, local]
                properties:
                  trusted: { type: boolean }
                  local: { type: boolean, description: "The addon runs on the panel's own server: a certificate an http-01 or tls-alpn-01 challenge proved fits it" }
                  sha256: { type: string }
                  notAfter: { type: integer, format: int64 }
                  pinned: { type: string, description: "The certificate of its own trusted before; empty for none" }
                  error: { type: string, description: "Why the address could not be read, the certificate aside" }
        "404": { $ref: "#/components/responses/Error" }
  /api/addons/{id}/certificate/pin:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Trust the certificate an addon serves now (sudo; no token) — a main admin holding every permission
      description: >
        sha256 must be the certificate the addon serves at this moment, one
        the panel does not trust otherwise (409 when it is another, or
        trusted already). Empty forgets the pin.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [sha256], properties: { sha256: { type: string } } }
      responses:
        "200": { description: Trusted, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "428": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/addons/self/certificate:
    get:
      tags: [addons]
      summary: The calling addon's certificate (an addon's token)
      description: >
        The PEM pair of the certificate the main admin chose for the addon
        whose token calls, and when it ends. Every addon token reaches it,
        whatever its scopes; a login, a token of no registered addon or of a
        suspended one is 403. The ETag is the leaf's SHA-256: sent back in
        If-None-Match, an unchanged certificate is 304. An addon fetches it
        on its own schedule; a renewal in the panel reaches it on its next
        fetch. 409 when the certificate is not issued yet, or no longer fits
        the addon — it became the panel's own HTTPS certificate, or one an
        http-01 or tls-alpn-01 challenge proved, for an addon not on this
        server.
      parameters:
        - { name: If-None-Match, in: header, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AddonCertificate" } } } }
        "304": { description: Unchanged }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /api/addons/self/certificate/claim:
    post:
      tags: [addons]
      security: []
      summary: An install's certificate, by its claim code (public)
      description: >
        Before it registers, an addon holding the claim code of a waiting
        install fetches the certificate chosen for it: the panel reads its
        manifest over https at the registration, so it serves the certificate
        first. Public, and limited on its own: wrong codes for an install that
        waits lock the address out of this route for a while (429) — not out
        of the login, and with no ban; a right code always answers (80
        random bits). A slug with no install waiting counts nothing. 403 for
        an address already banned; 409 as for GET /api/addons/self/certificate.
        The waiting install, and so its code, is gone once the addon
        registers.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [slug, claimCode], properties: { slug: { type: string }, claimCode: { type: string } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AddonCertificate" } } } }
        "304": { description: Unchanged }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /api/addons/{id}/suspend:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [addons]
      summary: Suspend an addon (sudo; no token) — a main admin holding every permission
      description: >
        Its token stops authenticating and its subscription stops receiving
        (events raised meanwhile are not queued for it); nothing is deleted
        and its health path is not asked. Suspending a suspended addon is a
        no-op.
      responses:
        "200": { description: Suspended, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/addons/{id}/resume:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [addons]
      summary: Resume a suspended addon (sudo; no token) — a main admin holding every permission
      description: The token authenticates again and the subscription is switched back on with a clean failure count. An addon whose unhealthy alert is still open comes back unhealthy, and its next healthy answer raises panel.addon_recovered; any other comes back registered with a clean health count.
      responses:
        "200": { description: Resumed, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/addons/{id}/check:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [addons]
      summary: Read a signed addon's manifest now (sudo; no token) — a main admin holding every permission
      description: >
        What the hourly check does, on demand. `result` is `same`, `applied`
        (a newer manifest asking for nothing more than the addon holds was
        applied) or `pending` (it asks for more and waits for approval).
        409 for an addon registered by hand, and for a manifest that drops
        the token while an `api` route admits it or the addon's slug; 502
        when the manifest cannot be read or is refused.
      responses:
        "200":
          description: Checked
          content:
            application/json:
              schema:
                type: object
                required: [result, addon]
                properties:
                  result: { type: string, enum: [same, applied, pending] }
                  addon: { $ref: "#/components/schemas/Addon" }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/addons/{id}/approve:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [addons]
      summary: Approve a signed addon's waiting update (sudo; no token) — a main admin holding every permission
      description: >
        The manifest is read again and must hash to `digest` (from
        `pending.digest`); its scopes and events then replace the addon's.
        409 when nothing waits, when the manifest changed since, or when the
        update asks for a token or a webhook the addon was never given —
        those credentials could only be handed over with a claim code, so
        the addon is removed and registered again.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [digest], properties: { digest: { type: string } } }
      responses:
        "200": { description: Applied, content: { application/json: { schema: { $ref: "#/components/schemas/Addon" } } } }
        "409": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/webhooks:
    get:
      tags: [webhooks]
      summary: The event subscribers (sudo)
      description: >
        Where events go. Each subscriber receives every event its `events`
        filter names (empty = all), narrowed by `adminId` to the events about
        that account when set. Deliveries are signed
        (`X-Nx-Signature: t=<unix>,v1=<hex>`, the hex being
        HMAC-SHA256(secret, "<unix>.<body>")), carry `X-Nx-Event` and
        `X-Nx-Delivery` (the id a receiver dedups on: a retry repeats it),
        and are retried with exponential backoff (30 s doubling to 1 h) up to
        10 attempts. A subscriber is switched off only once it has been
        failing for a day and at least 50 attempts — a receiver down for an
        hour keeps its backlog and receives it on return. The
        body is `{v, id, event, time, data, recipients?}`. Receivers should
        refuse a signature timestamp more than five minutes off.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/EventSubscriber" } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [webhooks]
      summary: Add a subscriber (sudo)
      description: >
        The response is the one time the signing `secret` is shown; one is
        generated when the body carries none. `kind: addon` is a subscriber
        that belongs to an addon's API token — it is what allows a loopback or
        private address, where an addon runs. An addon registers one through
        its own token (`tokenId` left out); the main admin makes one by hand
        for the operator's own addon by naming, in `tokenId`, an API token that
        carries an addon id. A token without an addon id may not make one
        (400). A `manual` subscriber (the default) takes no
        `tokenId` and is refused those addresses at dial time, so a URL alone
        cannot point the panel at its own network.


        A caller narrower than every scope — an API token with scopes, or a
        login on a narrowed role — may not make a subscriber that hears an
        event its scopes do not reach: such an event in `events`, or an empty
        `events` list on a `manual` subscriber (every event), is 400. Naming
        a `tokenId` is for a login holding every scope; any other login is
        403.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventSubscriberRequest" }
      responses:
        "200":
          description: Created; `secret` is present in this response only
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventSubscriber" }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
  /api/webhooks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [webhooks]
      summary: Update a subscriber (sudo)
      description: >-
        Name, URL, filter, account and the enabled switch. Re-enabling resets the failure count. The secret is not editable here; see the rotate route. A subscriber an addon holds (`managedBy`) is changed on the Addons page, and here takes only its switch, from a login (an API token is 409) (every other field sent as it is — a breaker that switched it off after an outage is undone this way; switching on a suspended addon's is 409); delete and rotate are 409. An addon's subscriber that is not the caller's own — another API token's, or any to a login narrower than every scope — is 403 here, on delete and on rotate. So is a `manual` subscriber whose stored filter hears an event the caller's scopes do not reach (an empty filter is every event), for a caller narrower than every scope; it is also 403 on redeliver. The event rules of the create apply: from a caller narrower than every scope, an event its scopes do not reach, or an empty list on a `manual` subscriber, is 400. On an `addon` subscriber only the events an edit adds are held to its token's grant (400 past it, or when the token has expired or its account is gone); events it already lists are kept, since delivery filters them. Changing the URL drops every delivery still pending or retrying: it is marked `dead` rather than sent to the new address.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventSubscriberRequest" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventSubscriber" }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [webhooks]
      summary: Remove a subscriber and its delivery log (sudo)
      description: An addon's subscriber that is not the caller's own, or a `manual` one hearing an event the caller's scopes do not reach, is 403, as on update.
      responses:
        "200": { description: Removed }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /api/webhooks/{id}/secret:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { name: id, in: path, required: true, schema: { type: integer } }
      tags: [webhooks]
      summary: Rotate the signing secret (sudo)
      description: The new secret is in this response and nowhere else afterwards; the old one stops working with the next delivery. An addon's subscriber that is not the caller's own, or a `manual` one hearing an event the caller's scopes do not reach, is 403, as on update.
      responses:
        "200":
          description: Rotated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventSubscriber" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /api/webhooks/{id}/ping:
    post:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      tags: [webhooks]
      summary: Send a test event now (sudo)
      description: >
        Delivers a `panel.ping` to this subscriber synchronously and answers
        with the delivery as recorded — the receiver's status code, or the
        reason the panel refused to connect. Works on a disabled subscriber,
        which is how one is tested before being switched on.
      responses:
        "200":
          description: The delivery
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventDelivery" }
        "404": { $ref: "#/components/responses/Error" }
  /api/webhooks/{id}/deliveries:
    get:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
      tags: [webhooks]
      summary: A subscriber's delivery log, newest first (sudo)
      description: Bounded by `event_retention_days` (default 7).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/EventDelivery" } }
        "404": { $ref: "#/components/responses/Error" }
  /api/webhooks/{id}/deliveries/{did}/redeliver:
    post:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: did, in: path, required: true, schema: { type: integer } }
      tags: [webhooks]
      summary: Queue a delivery again (sudo)
      description: Resets its attempt count and sends it once more under the same delivery id, so the receiver can tell a redelivery from a new event. From a caller narrower than every scope, a delivery carrying an event its scopes do not reach, or any delivery of a `manual` subscriber whose filter hears such an event, is 403.
      responses:
        "200": { description: Queued }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/services:
    get:
      tags: [services]
      summary: The panel services (sudo)
      description: >
        What the panel does outward for the operator — Telegram and email for
        admins, backup destinations, OIDC login — each off until switched on.
        Lists every service this build has, configured or not; one never
        configured is off with nothing set and no run. A secret setting is
        reported only as `set`, never its value.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/PanelService" } }
  /api/services/{key}:
    parameters:
      - { name: key, in: path, required: true, schema: { type: string } }
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [services]
      summary: Configure a service, switch it on or off (sudo)
      description: >
        A setting left out of `config` is unchanged; one sent as `""` is
        cleared, which is the only way to remove a secret. A setting the
        service does not own is refused, and so is switching a service on (or
        keeping it on) while a required setting is empty. The audit record
        names the settings that changed, never their values.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PanelServiceUpdate" }
      responses:
        "200":
          description: The service as it now stands
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PanelService" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/backup_telegram/document:
    post:
      tags: [services]
      summary: Send a file to the backup chat
      description: >
        Forwards one file of the caller's own — an addon's backup, say — to
        the Telegram chat the main admin chose for the panel's backups, through
        the panel's bot. The file is streamed straight to Telegram and never
        stored by the panel; encrypt it first, since the panel cannot look
        inside. It goes only to that chat, never to one the caller names. The
        caption names the sender (the addon, or the token or account), the
        file and its size.


        The body is the file itself (`application/octet-stream`) with a
        `Content-Length`; `name` is the file name the chat shows. At most
        50 MB (the Telegram Bot API's cap), and 6 files an hour per caller.
        `backup_telegram` must be on — otherwise `409`, and the caller sends
        the file another way. Scope `backup:deliver`. An `Idempotency-Key` is
        ignored here: the body is not buffered, and a second send is a second
        copy in the chat.
      parameters:
        - { name: name, in: query, required: true, schema: { type: string, maxLength: 128 }, description: "The file's name, without a path." }
      requestBody:
        required: true
        content:
          application/octet-stream: { schema: { type: string, format: binary } }
      responses:
        "200":
          description: Sent
          content:
            application/json:
              schema:
                type: object
                required: [sent, name, size]
                properties:
                  sent: { type: boolean }
                  name: { type: string }
                  size: { type: integer, format: int64 }
        "400": { $ref: "#/components/responses/Error" }
        "409": { description: "backup_telegram is off, or its chat or the Telegram service cannot be used now; the reason is in the body.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "411": { description: "No Content-Length.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "413": { description: "Over 50 MB.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: "Six files this hour already; Retry-After says when the next may go.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: "Telegram did not take the file.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /api/services/{key}/test:
    parameters:
      - { name: key, in: path, required: true, schema: { type: string } }
    post:
      tags: [services]
      summary: Test a service with its stored settings (sudo)
      description: >
        Runs the service's own test (a test message, a dial of the storage,
        the issuer's metadata) against what is stored, not against an unsaved
        form, and records it as the service's last run. A service that is
        switched off can be tested; one with no test answers 400.
      responses:
        "200":
          description: The test ran; `ok` says how it went
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  error: { type: string, description: "Why it failed; absent when ok" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/me/telegram:
    get:
      tags: [services]
      summary: The caller's own Telegram chats (session only)
      description: >
        Any account — operator or reseller — pairs its own chat here, as long
        as the main admin has the Telegram service on (`available`). A chat
        hears what its account may read. Closed to API tokens.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
                  chats: { type: array, items: { $ref: "#/components/schemas/TelegramChat" } }
  /api/me/telegram/events:
    get:
      tags: [services]
      summary: The events the caller's chat can be given (session only)
      parameters:
        - { name: lang, in: query, schema: { type: string, enum: [en, fa, ru, zh] } }
      responses:
        "200": { description: As GET /api/services/telegram/events for the caller's account }
  /api/me/telegram/pair:
    post:
      tags: [services]
      summary: Issue a pairing code for the caller's own account (session only)
      description: As POST /api/services/telegram/pair with `adminId` fixed to the caller; 409 while the service is off.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                lang: { type: string, enum: [en, fa, ru, zh] }
                events: { type: array, items: { type: string } }
      responses:
        "200": { description: The code, pending, content: { application/json: { schema: { $ref: "#/components/schemas/TelegramPairing" } } } }
        "409": { $ref: "#/components/responses/Error" }
  /api/me/telegram/pair/{code}:
    get:
      parameters:
        - { name: code, in: path, required: true, schema: { type: string } }
      tags: [services]
      summary: Where the caller's own pairing code stands (session only)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/TelegramPairing" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/me/telegram/chats/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    put:
      tags: [services]
      summary: Narrow, switch or re-language one of the caller's chats (session only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                events: { type: array, items: { type: string } }
                enabled: { type: boolean }
                lang: { type: string, enum: [en, fa, ru, zh] }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/TelegramChat" } } } }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [services]
      summary: Unpair one of the caller's chats (session only)
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/Error" }
  /api/me/telegram/chats/{id}/deliveries:
    get:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
      tags: [services]
      summary: The delivery log of one of the caller's chats, newest first (session only)
      description: As GET /api/webhooks/{id}/deliveries — every notice sent to the chat, its status, attempts and the channel's answer; bounded by `event_retention_days`. Another account's chat reads as 404.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/EventDelivery" } }
        "404": { $ref: "#/components/responses/Error" }
  /api/me/telegram/chats/{id}/deliveries/{did}/redeliver:
    post:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: did, in: path, required: true, schema: { type: integer } }
      tags: [services]
      summary: Send one of the chat's notices again (session only)
      description: As POST /api/webhooks/{id}/deliveries/{did}/redeliver — queued again with a fresh attempt count. Another account's chat reads as 404.
      responses:
        "200": { description: Queued }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/telegram/pair:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [services]
      summary: Issue a code that pairs a Telegram chat to an account (sudo)
      description: >
        Checks the bot (getMe) and answers a one-time code and a t.me link that
        sends it. Whoever opens the link, or sends the code to the bot, pairs
        their chat to the account for ten minutes. The panel reads the bot's
        updates while the service is on (the chat commands /status, /user and
        /node) and, while it is off, only while a code is waiting. The chat then hears the events
        that account's role could read, and a reseller's chat only those about
        its own users. `adminId` defaults to the caller; `lang` (en, fa, ru,
        zh) is the language its messages are written in. A bot with a webhook
        set, or read by another program, cannot be polled and the code
        reports that error.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                adminId: { type: integer }
                lang: { type: string, enum: [en, fa, ru, zh] }
                events: { type: array, items: { type: string }, description: "The chat's filter from the start; empty = every event the account may read, including ones a later release adds" }
      responses:
        "200": { description: The code, pending, content: { application/json: { schema: { $ref: "#/components/schemas/TelegramPairing" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/services/telegram/pair/{code}:
    get:
      parameters:
        - { name: code, in: path, required: true, schema: { type: string } }
      tags: [services]
      summary: Where a pairing code stands (sudo)
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/TelegramPairing" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/telegram/events:
    get:
      tags: [services]
      summary: The events a chat can be given, for the pickers (sudo)
      description: >
        The event catalog grouped by family, each titled in `lang` (the page's
        language; default en). With `adminId`, `allowed` marks the events that
        account's role can read — any other is never delivered to its chat,
        whatever the chat's filter says.
      parameters:
        - { name: lang, in: query, schema: { type: string, enum: [en, fa, ru, zh] } }
        - { name: adminId, in: query, schema: { type: integer } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    name: { type: string }
                    family: { type: string, enum: [user, node, admin, panel] }
                    title: { type: string }
                    allowed: { type: boolean }
        "400": { $ref: "#/components/responses/Error" }
  /api/services/telegram/chats:
    get:
      tags: [services]
      summary: The paired Telegram chats (sudo)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/TelegramChat" } }
  /api/services/telegram/chats/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [services]
      summary: Narrow what a chat hears, switch it, or change its language (sudo)
      description: The account and the chat are fixed; pair again to move a chat. Re-enabling resets the failure count.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                events: { type: array, items: { type: string }, description: "Names from GET /api/events; empty = every event the account may read" }
                enabled: { type: boolean }
                lang: { type: string, enum: [en, fa, ru, zh] }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/TelegramChat" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [services]
      summary: Unpair a chat (sudo)
      description: Removes the chat with its subscriber and delivery log.
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/telegram/chats/{id}/test:
    post:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      tags: [services]
      summary: Send the test message to one chat now (sudo)
      description: A panel.ping through the ordinary delivery path, answered with the delivery as recorded.
      responses:
        "200": { description: The delivery, content: { application/json: { schema: { $ref: "#/components/schemas/EventDelivery" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/telegram/chats/{id}/deliveries:
    get:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
      tags: [services]
      summary: A chat's delivery log, newest first (sudo)
      description: As GET /api/webhooks/{id}/deliveries — every notice sent to the chat, its status, attempts and the channel's answer; bounded by `event_retention_days`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/EventDelivery" } }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/telegram/chats/{id}/deliveries/{did}/redeliver:
    post:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: did, in: path, required: true, schema: { type: integer } }
      tags: [services]
      summary: Send one of the chat's notices again (sudo)
      description: As POST /api/webhooks/{id}/deliveries/{did}/redeliver — queued again with a fresh attempt count.
      responses:
        "200": { description: Queued }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/email/events:
    get:
      tags: [services]
      summary: The events an address can be given, for the pickers (sudo)
      description: As GET /api/services/telegram/events — the same catalog, titles and `allowed` marking.
      parameters:
        - { name: lang, in: query, schema: { type: string, enum: [en, fa, ru, zh] } }
        - { name: adminId, in: query, schema: { type: integer } }
      responses:
        "200": { description: As GET /api/services/telegram/events }
        "400": { $ref: "#/components/responses/Error" }
  /api/services/email/recipients:
    get:
      tags: [services]
      summary: The confirmed email addresses (sudo)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/EmailRecipient" } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [services]
      summary: Mail a confirmation code to an address for an account (sudo)
      description: >
        Sends a six-digit code to `address` through the stored SMTP settings
        (the service may still be off — this is how they are proven) and
        answers the pending confirmation. The address is added only when the
        code comes back through …/recipients/confirm, within ten minutes and
        five tries. It then hears the events that account's role could read,
        a reseller's only those about its own users. `adminId` defaults to
        the caller; `lang` is the language its mail is written in. One code
        per account every thirty seconds (429 with Retry-After); a server
        that refuses the mail is a 502 carrying its answer.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                adminId: { type: integer }
                address: { type: string }
                lang: { type: string, enum: [en, fa, ru, zh] }
                events: { type: array, items: { type: string }, description: "The address's filter from the start; empty = every event the account may read, including ones a later release adds" }
      responses:
        "200": { description: The code was sent, content: { application/json: { schema: { $ref: "#/components/schemas/EmailPending" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/services/email/recipients/confirm:
    post:
      tags: [services]
      summary: Confirm an address with the code mailed to it (sudo)
      description: >
        A wrong code is a 400 naming the tries left; the fifth wrong one
        drops the code. An unknown or expired `id` is a 404. The same
        address confirmed again for the same account replaces the old one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, code]
              properties:
                id: { type: string, description: "From the pending confirmation" }
                code: { type: string }
      responses:
        "200": { description: The address, content: { application/json: { schema: { $ref: "#/components/schemas/EmailRecipient" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/email/recipients/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [services]
      summary: Narrow what an address hears, switch it, or change its language (sudo)
      description: The address and the account are fixed; confirm another address to change either. Re-enabling resets the failure count.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailRecipientUpdate" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/EmailRecipient" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [services]
      summary: Remove an address (sudo)
      description: Removes the address with its subscriber and delivery log.
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/email/recipients/{id}/test:
    post:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      tags: [services]
      summary: Mail the test notice to one address now (sudo)
      description: A panel.ping through the ordinary delivery path, answered with the delivery as recorded; its status code is the SMTP server's reply.
      responses:
        "200": { description: The delivery, content: { application/json: { schema: { $ref: "#/components/schemas/EventDelivery" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/email/recipients/{id}/deliveries:
    get:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
      tags: [services]
      summary: An address's delivery log, newest first (sudo)
      description: As GET /api/webhooks/{id}/deliveries — every notice sent to the address, its status, attempts and the channel's answer; bounded by `event_retention_days`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/EventDelivery" } }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/email/recipients/{id}/deliveries/{did}/redeliver:
    post:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: did, in: path, required: true, schema: { type: integer } }
      tags: [services]
      summary: Send one of the address's notices again (sudo)
      description: As POST /api/webhooks/{id}/deliveries/{did}/redeliver — queued again with a fresh attempt count.
      responses:
        "200": { description: Queued }
        "404": { $ref: "#/components/responses/Error" }
  /api/me/email:
    get:
      tags: [services]
      summary: The caller's own email addresses (session only)
      description: >
        Any account — operator or reseller — adds its own address here, as
        long as the main admin has the email service on (`available`). An
        address hears what its account may read. Closed to API tokens.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  available: { type: boolean }
                  recipients: { type: array, items: { $ref: "#/components/schemas/EmailRecipient" } }
  /api/me/email/events:
    get:
      tags: [services]
      summary: The events the caller's address can be given (session only)
      parameters:
        - { name: lang, in: query, schema: { type: string, enum: [en, fa, ru, zh] } }
      responses:
        "200": { description: As GET /api/services/telegram/events for the caller's account }
  /api/me/email/recipients:
    post:
      tags: [services]
      summary: Mail a confirmation code to the caller's own address (session only)
      description: As POST /api/services/email/recipients with `adminId` fixed to the caller; 409 while the service is off.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address: { type: string }
                lang: { type: string, enum: [en, fa, ru, zh] }
                events: { type: array, items: { type: string } }
      responses:
        "200": { description: The code was sent, content: { application/json: { schema: { $ref: "#/components/schemas/EmailPending" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/me/email/recipients/confirm:
    post:
      tags: [services]
      summary: Confirm the caller's own address (session only)
      description: As POST /api/services/email/recipients/confirm; another account's pending address reads as 404.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, code]
              properties:
                id: { type: string }
                code: { type: string }
      responses:
        "200": { description: The address, content: { application/json: { schema: { $ref: "#/components/schemas/EmailRecipient" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/me/email/recipients/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    put:
      tags: [services]
      summary: Narrow, switch or re-language one of the caller's addresses (session only)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailRecipientUpdate" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/EmailRecipient" } } } }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [services]
      summary: Remove one of the caller's addresses (session only)
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/Error" }
  /api/me/email/recipients/{id}/deliveries:
    get:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
      tags: [services]
      summary: The delivery log of one of the caller's addresses, newest first (session only)
      description: As GET /api/webhooks/{id}/deliveries — every notice sent to the address, its status, attempts and the channel's answer; bounded by `event_retention_days`. Another account's address reads as 404.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/EventDelivery" } }
        "404": { $ref: "#/components/responses/Error" }
  /api/me/email/recipients/{id}/deliveries/{did}/redeliver:
    post:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: did, in: path, required: true, schema: { type: integer } }
      tags: [services]
      summary: Send one of the address's notices again (session only)
      description: As POST /api/webhooks/{id}/deliveries/{did}/redeliver — queued again with a fresh attempt count. Another account's address reads as 404.
      responses:
        "200": { description: Queued }
        "404": { $ref: "#/components/responses/Error" }
  /api/login/oidc:
    get:
      tags: [auth]
      security: []
      summary: The login page's single sign-on buttons (public)
      description: The providers that are switched on, in order — which button, nothing about their settings.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  providers: { type: array, items: { $ref: "#/components/schemas/SSOButton" } }
  /api/login/oidc/start:
    post:
      tags: [auth]
      security: []
      summary: Start a sign-in at one provider (public)
      description: >
        Answers the provider's authorization URL (code flow with PKCE, a
        state, and for OpenID Connect a nonce) for the browser to go to. `redirectUri` is this
        panel's /api/login/oidc/callback as the browser sees it, on the host
        the request came to — the address to register at the provider. The
        sign-in must come back within ten minutes, in the same browser: the
        answer sets the `nx_oidc` cookie, and the callback refuses a
        state that browser was not given. Refused from a banned address;
        409 for a provider that is off, 429 when the address already has ten
        sign-ins waiting or the panel a thousand.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, redirectUri]
              properties:
                provider: { type: integer, description: "A provider id from GET /api/login/oidc" }
                redirectUri: { type: string }
                remember: { type: boolean, description: "The long session, as on the password login" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, properties: { url: { type: string } } } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/login/oidc/callback:
    get:
      tags: [auth]
      security: []
      summary: Where the provider sends the browser back (public)
      description: >
        One callback for every provider. Exchanges the code and verifies the
        id_token (signature against the issuer's keys, issuer, audience,
        expiry, nonce) — or, for an OAuth 2.0 provider without OpenID Connect,
        reads the user endpoint with the access token. Every answer is a 302
        into the SPA. A linked identity signs in — `/` with the session
        cookie, or `login?sso=mfa#<pending>` for an account with TOTP, to be
        finished at POST /api/login/mfa — and anything else lands on
        `login?sso=<unlinked|error|expired|blocked|account-expired>`, the
        same address whoever the identity was. A link started from
        POST /api/me/oidc/link lands on `dashboard?sso=<linked|link-taken|link-error>`.
      parameters:
        - { name: code, in: query, schema: { type: string } }
        - { name: state, in: query, schema: { type: string } }
        - { name: error, in: query, schema: { type: string } }
      responses:
        "302": { description: Back into the panel }
  /api/me/oidc:
    get:
      tags: [auth]
      summary: The caller's own single sign-on links (session only)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  providers: { type: array, items: { $ref: "#/components/schemas/SSOButton" }, description: "The providers it can link with" }
                  links: { type: array, items: { $ref: "#/components/schemas/SSOLink" } }
  /api/me/oidc/link:
    post:
      tags: [auth]
      summary: Link an identity at the provider to the caller (session only)
      description: As POST /api/login/oidc/start, but whoever signs in at the provider is linked to the calling account instead of logged in. Costs the account's current password (403 when wrong, counted like a failed login), since a link outlives a password change. The link is made only while the login that started it is still there and the account's password unchanged. An identity linked to another account is refused (`link-taken`). 409 for a provider that is off.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, redirectUri, password]
              properties:
                provider: { type: integer }
                redirectUri: { type: string }
                password: { type: string, description: "The account's current password" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, properties: { url: { type: string } } } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /api/me/oidc/links/{id}:
    delete:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      tags: [auth]
      summary: Unlink one of the caller's identities (session only)
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/oidc/presets:
    get:
      tags: [services]
      summary: The kinds of sign-in provider and the settings each takes (a main admin's login; an API token is 403)
      description: >
        google, microsoft, github, gitlab, keycloak, authentik, okta, auth0,
        oidc (any OpenID Connect provider by its issuer) and oauth2 (any
        OAuth 2.0 provider by its addresses), in the picker's order. A
        setting with a `default` takes it when left empty.
      responses:
        "403": { $ref: "#/components/responses/Error" }
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    kind: { type: string }
                    protocol: { type: string, enum: [oidc, oauth2] }
                    fields:
                      type: array
                      items:
                        type: object
                        properties:
                          key: { type: string }
                          secret: { type: boolean }
                          required: { type: boolean }
                          default: { type: string }
  /api/services/oidc/providers:
    get:
      tags: [services]
      summary: The sign-in providers (a main admin's login; an API token is 403)
      responses:
        "403": { $ref: "#/components/responses/Error" }
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SSOProvider" } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [services]
      summary: Add a sign-in provider (a main admin's login; an API token is 403)
      description: >
        `kind` is one of the presets and fixes which settings `config` may
        carry; a setting the kind does not have is refused, and so is
        switching the provider on while a required one is empty. At most 20.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SSOProviderWrite" }
      responses:
        "403": { $ref: "#/components/responses/Error" }
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SSOProvider" } } } }
        "400": { $ref: "#/components/responses/Error" }
  /api/services/oidc/providers/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [services]
      summary: Change a sign-in provider (a main admin's login; an API token is 403)
      description: >-
        A setting left out of `config` is unchanged; `""` clears it. The kind cannot change. A change to
        who the provider says people are — its client id, its issuer (resolved, so a trailing slash or a typed default
        changes nothing), or for a plain OAuth 2.0 provider any of its three addresses or its subject field — removes
        every link made through it, in the same write; each account links again with its password.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SSOProviderWrite" }
      responses:
        "403": { $ref: "#/components/responses/Error" }
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SSOProvider" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [services]
      summary: Remove a sign-in provider and the identities linked through it (a main admin's login; an API token is 403)
      responses:
        "403": { $ref: "#/components/responses/Error" }
        "200": { description: OK }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/oidc/providers/{id}/test:
    post:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      tags: [services]
      summary: Check a provider with its saved settings (a main admin's login; an API token is 403)
      description: OpenID Connect — its metadata and keys; OAuth 2.0 — that its authorization address answers. Recorded as the provider's last run.
      responses:
        "403": { $ref: "#/components/responses/Error" }
        "200":
          description: The check ran; `ok` says how it went
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  error: { type: string }
        "404": { $ref: "#/components/responses/Error" }
  /api/services/oidc/links:
    get:
      tags: [services]
      summary: Every linked identity, with its account (a main admin's login; an API token is 403)
      responses:
        "403": { $ref: "#/components/responses/Error" }
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/SSOLink" } }
  /api/services/oidc/links/{id}:
    delete:
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [services]
      summary: Unlink any account's identity (a main admin's login; an API token is 403)
      responses:
        "403": { $ref: "#/components/responses/Error" }
        "200": { description: OK }
        "404": { $ref: "#/components/responses/Error" }
  /api/backup:
    get:
      tags: [settings]
      summary: Download a database backup (sudo only)
      description: >
        Streams a portable, logical dump of the whole panel database: one
        NDJSON file per table inside a gzipped tar, with a `manifest.json`
        first. It restores across both database backends, which also makes it
        the SQLite↔PostgreSQL migration path.


        The archive carries **every credential the panel holds** — admin
        password hashes, user UUIDs, certificate private keys, the mTLS client
        key and each node's pinned certificate — because a dump without them
        restores a panel nobody can log into and that reaches no node. Treat
        the file as equivalent to the panel itself, and prefer encrypting it
        with `X-Backup-Passphrase`.


        What is *not* in the archive: the config file naming the database, the
        subscription themes on disk, and any staged node binaries.


        `idempotency_records` and `acme_locks` are always excluded: both are
        transient and expire. The two size levers below leave out the traffic
        history and the audit log; neither holds live state (a user's spent
        quota is on the user row), so a config-only archive is a complete
        restore of everything operational.
      parameters:
        - name: history
          in: query
          schema: { type: boolean, default: false }
          description: Include the traffic time series and hourly rollups (stats, node_usages, node_client_usages). These are the bulk of a busy panel's database.
        - name: audit
          in: query
          schema: { type: boolean, default: false }
          description: Include the audit log (changes).
        - name: X-Backup-Passphrase
          in: header
          required: false
          schema: { type: string }
          description: >
            When set, the archive is encrypted with it (scrypt + chunked
            AES-256-GCM) and cannot be read without it — there is no recovery.
            A header rather than a query parameter, because a query string
            reaches browser history, proxies and access logs.
      responses:
        "200":
          description: The archive
          content:
            application/gzip:
              schema: { type: string, format: binary }
        "403": { $ref: "#/components/responses/Error" }
  /api/backup/preview:
    post:
      tags: [settings]
      summary: Validate a backup archive without restoring it (sudo only)
      description: >
        Reads an uploaded archive end to end and reports what it holds. It
        touches no panel data and changes nothing.


        Verification is against the archive's own manifest: every table it
        claims must be present with exactly the row count it claims, and every
        row must parse. That is what catches the failure that actually happens
        — a half-finished download or a partly written file — before it is
        restored rather than after.


        The request body is the archive itself; there is no multipart wrapper,
        so nothing is spooled to a temporary file.
      requestBody:
        required: true
        content:
          application/gzip:
            schema: { type: string, format: binary }
      parameters:
        - name: X-Backup-Passphrase
          in: header
          required: false
          schema: { type: string }
          description: Required for an encrypted archive; omitting it answers 400 with code `encrypted`.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/BackupPreview" } } } }
        "400":
          description: >
            Not a readable archive. The body carries a stable `code` alongside
            `error`: `encrypted` (a passphrase is needed), `passphrase` (wrong
            passphrase, or a damaged file — AEAD cannot tell them apart), or
            `format` (not a backup, truncated, or a row count that disagrees
            with the manifest).
          content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } }
        "403": { $ref: "#/components/responses/Error" }
        "413": { description: Larger than 1 GiB, content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }

  /api/backup/restore:
    post:
      tags: [settings]
      summary: Replace the database from a backup archive (sudo only, no token may call it) — a main admin holding every permission
      description: >
        Stages an uploaded archive as the panel's next database and restarts to
        apply it.


        **Nothing is written into the database the panel is serving out of.**
        The replacement is built as a separate file beside it, opened, migrated
        and checked for an admin who can log in; only then is a marker left
        asking the next start-up to swap the two files. Every failure — a
        truncated archive, a row that will not load, an archive with no admins
        — therefore costs an error and leaves the running panel untouched.


        A full backup of the current database (traffic history and audit log
        included) is written into the backup directory first and its path
        returned as `snapshot`, so a restore is always undoable from the host
        it ran on. It is encrypted with the schedule's passphrase when one is
        set — an install that configured one did so precisely so that no
        plaintext archive touches its disk. Retention never deletes it.


        On PostgreSQL there is no file to swap, so the safety is bought a
        different way: the truncate, the load, the sequence reset and the
        admin check all run in one transaction, and a failure at any point
        rolls back to the database that was already there. The restore is then
        already applied — `staged` is false — and the restart only reloads what
        the process holds in memory. A SQLite panel on an in-memory or URI DSN
        has nothing to swap and answers 501 with code `unsupported`, rather
        than reporting a success that vanishes on restart.


        No API token may call this route, whatever its scopes: it rewrites
        every admin password, so a token that could call it could make itself
        permanent.
      parameters:
        - name: confirm
          in: query
          required: true
          schema: { type: string, enum: [replace-database] }
          description: Must be the literal `replace-database`. A restore replaces every row the install has, and must not be reachable by retrying the wrong command.
        - name: keep_web_settings
          in: query
          schema: { type: boolean, default: true }
          description: >
            Keep this installation's own address settings (web_domain,
            web_basepath, web_listen_ip, web_listen_port, web_tls_mode,
            web_cert_file, web_key_file, web_cert_id) instead of the archive's.
            Taking the archive's points the listener at an address this machine
            may not have, or demands a Host header that does not resolve here —
            and the panel that comes back up is the one that would have to undo
            it. Absent means on.
        - name: keep_license
          in: query
          schema: { type: boolean, default: true }
          description: >
            Keep this installation's licence token instead of the archive's.
            A licence belongs to one installation, so an archive from another
            machine carries one that cannot validate here. Absent means on.
        - name: X-Backup-Passphrase
          in: header
          required: false
          schema: { type: string }
          description: Required for an encrypted archive.
      requestBody:
        required: true
        content:
          application/gzip:
            schema: { type: string, format: binary }
      responses:
        "200": { description: Loaded — staged or already applied, see `staged`; the panel is restarting, content: { application/json: { schema: { $ref: "#/components/schemas/BackupRestore" } } } }
        "400":
          description: >
            Refused, with a stable `code`: `confirm` (the confirmation is
            missing or wrong), `version_mismatch` (another panel version
            wrote the archive: a backup is restored only into the version
            that took it, refused before anything is written), `no_admins`
            (the archive holds no admin
            accounts, so restoring it would leave a panel nobody can log into),
            `unusable_ingress` (the restored panel's addresses would not work:
            an ingress the Ingress page would refuse, or one no main admin
            could sign in through — the preview warns of it first as
            `unusable_ingress` or `unusable_kept_address`), `encrypted`,
            `passphrase`, or `format`.
          content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } }
        "403": { $ref: "#/components/responses/Error" }
        "409": { description: A restore is already in progress (code `busy`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "413": { description: Larger than 1 GiB (code `too_large`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "500": { $ref: "#/components/responses/Error" }
        "501": { description: This database or runtime cannot be restored in place (code `unsupported`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }

  /api/backups:
    get:
      tags: [settings]
      summary: List the backup archives kept on the panel host (sudo only)
      description: >
        Newest first, with each archive's manifest read in place so a directory
        of files is legible without downloading any of them. An encrypted
        archive is listed with `encrypted: true` and no manifest — that is what
        encrypting it means.


        Files in the directory that are not this panel's own archives are
        ignored rather than listed: offering a restore of something the panel
        never wrote is worse than not showing it.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/BackupList" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
    post:
      parameters:
        - { name: history, in: query, schema: { type: boolean, default: false }, description: Include the traffic time series and hourly rollups. }
        - { name: audit, in: query, schema: { type: boolean, default: false }, description: Include the audit log. }
        - name: X-Backup-Passphrase
          in: header
          required: false
          schema: { type: string }
          description: Encrypts this archive. Omitted, the schedule's own passphrase is used, so the directory is never half encrypted.
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Take a backup onto the panel host now (sudo only)
      description: >
        Writes an archive into the backup directory rather than through the
        caller's browser, which is what an operator wants before a risky
        change.


        The file is written under a temporary name and renamed once complete,
        so a backup interrupted half way — the process killed, the volume full
        — never appears in the listing as something restorable.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/BackupFile" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/backups/schedule:
    get:
      tags: [settings]
      summary: The scheduled-backup configuration (sudo only)
      description: >
        `passphraseSet` reports whether scheduled archives are encrypted; the
        passphrase itself is never returned. `nextDue` is derived from the
        newest archive on disk rather than stored, so a panel that was switched
        off does not come back holding a stale appointment.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/BackupSchedule" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
    put:
      tags: [settings]
      summary: Save the scheduled-backup configuration (sudo only)
      description: >
        The passphrase is write-only. An omitted or empty `passphrase` leaves
        the stored one alone — a form that saved what it was shown, and it is
        never shown one, would otherwise silently stop encrypting every archive
        from then on. Clearing it is the separate `clearPassphrase` flag.


        Retention is applied on the hourly tick whether the schedule is on or
        off and whether or not a backup was due, because archives also arrive
        from `POST /api/backups`. Pre-restore snapshots are never pruned.
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BackupScheduleUpdate" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/BackupSchedule" } } } }
        "400": { description: "An unreadable body, a negative `intervalHours` or `keep`, a relative `dir`, an interval above a year, or `passphrase` sent together with `clearPassphrase` — which of the two was meant is not something to guess at.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/backups/{name}:
    parameters:
      - name: name
        in: path
        required: true
        schema: { type: string }
        description: The archive's file name, exactly as the listing gives it. Any other name is refused — it never addressed a file.
    get:
      tags: [settings]
      summary: Download one stored archive (sudo only)
      description: Answers Range requests, so a failed download of a large archive resumes rather than starting again.
      responses:
        "200": { description: The archive, content: { application/gzip: { schema: { type: string, format: binary } } } }
        "400": { description: Not a backup file name (code `format`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
    delete:
      tags: [settings]
      summary: Delete one stored archive (sudo only)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, properties: { status: { type: string } } } } } }
        "400": { description: Not a backup file name (code `format`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/backups/{name}/preview:
    parameters:
      - { name: name, in: path, required: true, schema: { type: string } }
    post:
      tags: [settings]
      summary: Validate a stored archive without restoring it (sudo only)
      description: >
        `POST /api/backup/preview` for an archive already on the host: the
        server opens its own file, so checking a stored backup is not a
        download of the whole credential store followed by an upload of it.
        Same response, same warnings, same `X-Backup-Passphrase` header for an
        encrypted one.
      parameters:
        - { name: X-Backup-Passphrase, in: header, required: false, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/BackupPreview" } } } }
        "400": { description: Not a readable archive, or not a backup file name, content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/backups/{name}/restore:
    parameters:
      - { name: name, in: path, required: true, schema: { type: string } }
    post:
      tags: [settings]
      summary: Replace the database from a stored archive (sudo only, no token may call it) — a main admin holding every permission
      description: >
        `POST /api/backup/restore` for an archive already on the host. Same
        confirmation, same guards, same restart, same refusal to every API
        token — only the source differs.
      parameters:
        - { name: confirm, in: query, required: true, schema: { type: string, enum: [replace-database] } }
        - { name: keep_web_settings, in: query, schema: { type: boolean, default: true } }
        - { name: keep_license, in: query, schema: { type: boolean, default: true } }
        - { name: X-Backup-Passphrase, in: header, required: false, schema: { type: string } }
      responses:
        "200": { description: Staged or applied; the panel is restarting, content: { application/json: { schema: { $ref: "#/components/schemas/BackupRestore" } } } }
        "400": { description: "Refused: `confirm`, `version_mismatch`, `no_admins`, `unusable_ingress`, `encrypted`, `passphrase` or `format`", content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { description: A restore is already in progress (code `busy`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
        "500": { $ref: "#/components/responses/Error" }
        "501": { description: This database or runtime cannot be restored in place (code `unsupported`), content: { application/json: { schema: { $ref: "#/components/schemas/BackupError" } } } }
  /api/mtls/certificate:
    get:
      tags: [install]
      summary: The Panel mTLS client certificate (PEM)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, properties: { certificate: { type: string } } } } } }
  /api/tokens/node-install:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [install]
      summary: Create a single-use node-install token (sudo only)
      description: >
        Also answers `pin` when a node could not verify the panel's HTTPS
        certificate on its own — the request came over the panel's own TLS,
        not through a trusted proxy, and the certificate it serves does not
        chain to the system roots for that host (the setup wizard's
        self-signed one). The command the panel shows then accepts the panel
        by that key: `curl -k --pinnedpubkey <pin>` and the installer's
        `--pin <pin>`.
      requestBody:
        content: { application/json: { schema: { type: object, properties: { desc: { type: string }, expiry: { type: integer, format: int64 } } } } }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/InstallToken" } } } } }

  /install-node.sh:
    get:
      tags: [install]
      summary: The node installer script
      security: []
      responses:
        "200": { description: Shell script, content: { text/x-shellscript: { schema: { type: string } } } }
  /install/ca:
    get:
      tags: [install]
      summary: Fetch the Panel client CA PEM (guarded by a single-use token)
      security: []
      parameters: [ { name: token, in: query, required: true, schema: { type: string } } ]
      responses:
        "200": { description: PEM, content: { application/x-pem-file: { schema: { type: string } } } }
        "401": { $ref: "#/components/responses/Error" }
  /install/node-binary:
    get:
      tags: [install]
      summary: Download the node binary for an arch
      security: []
      parameters: [ { name: arch, in: query, schema: { type: string, enum: [amd64, arm64] } } ]
      responses:
        "200": { description: Binary, content: { application/octet-stream: { schema: { type: string, format: binary } } } }

  /sub/{token}:
    get:
      tags: [subscription]
      summary: A user's subscription content (public, token in path)
      description: >-
        Returns the client configuration in the format the request asks for
        (?format=, or detected from the User-Agent, or the sub_fallback_format
        setting). A browser navigation — an Accept header listing text/html with
        no known client User-Agent — gets the server-rendered subscription page
        instead, unless sub_page_disabled is set. Every response carries
        Subscription-Userinfo, Profile-Title, Profile-Update-Interval and
        Profile-Web-Page-Url.

        A client that identifies its device with an `x-hwid` header (with
        `x-device-os`, `x-ver-os` and `x-device-model` beside it) is recorded
        under the user's devices. With a `deviceLimit` set, a device the user
        has not got room for is refused with 403 — page and body alike, since
        the page carries the links too — and a client sending no id is served
        unless the device_limit_strict setting is on.
      security: []
      parameters: [ { name: token, in: path, required: true, schema: { type: string } } ]
      responses:
        "200": { description: Subscription, content: { text/plain: { schema: { type: string } }, text/html: { schema: { type: string } } } }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /sub/{token}/page:
    get:
      tags: [subscription]
      summary: The subscription page for a user (public, token in path)
      description: Renders the active theme regardless of the Accept header — how an operator previews a theme.
      security: []
      parameters: [ { name: token, in: path, required: true, schema: { type: string } } ]
      responses:
        "200": { description: HTML page, content: { text/html: { schema: { type: string } } } }
        "404": { $ref: "#/components/responses/Error" }
  /sub/{token}/info:
    get:
      tags: [subscription]
      summary: The subscription page's view model as JSON (public, token in path)
      description: >-
        The same object a theme is rendered with, minus the configurations
        themselves (links are omitted and config/app bodies are blanked), so a
        page can poll for live usage and online state without reloading.
      security: []
      parameters: [ { name: token, in: path, required: true, schema: { type: string } } ]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SubscriptionInfo" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /sub/{token}/_assets/{path}:
    get:
      tags: [subscription]
      summary: A static file of the active subscription theme (public, token in path)
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
        - { name: path, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: The file }
        "404": { $ref: "#/components/responses/Error" }
  /api/settings/incy-key/refresh:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Fetch the INCY deep-link key from INCY and store it
      description: >
        INCY clients import a subscription from an encrypted deep link
        (incy://crypt1/…) sealed with one AES-256 key every client carries. The
        panel ships no key: this downloads INCY's published link encoder (the
        Go port of github.com/INCY-DEV/incy-link-encoder), derives the key the
        way the encoder does, checks it against the fingerprint the same source
        pins, and stores it as sub_incy_key together with the scheme name as
        sub_incy_scheme. From then on every subscription page renders the
        encrypted INCY link instead of incy://add/. Nothing is written when the
        download or the fingerprint check fails (502). Operators only; the same
        keys can be set by hand with PUT /api/settings/{key}.
      responses:
        "200":
          description: The key that was stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { type: string, description: "AES-256 key, 64 hex characters (sub_incy_key)" }
                  fingerprint: { type: string, description: "SHA-256 of the key, as INCY publishes it" }
                  scheme: { type: string, description: "Encrypted link host segment, e.g. crypt1 (sub_incy_scheme)" }
                  version: { type: string, description: "Version of INCY's encoder the key was derived from" }
        "502": { $ref: "#/components/responses/Error" }

  /api/settings/v2raytun-key/refresh:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [settings]
      summary: Fetch the V2RayTun deep-link key from V2RayTun and store it
      description: >
        V2RayTun clients import a subscription from an encrypted deep link
        (v2raytun://crypt/…) sealed with V2RayTun's RSA-4096 public key — only
        the app holds the private half. The panel ships no key: this downloads
        V2RayTun's published crypto-link documentation, takes the public key and
        the link's scheme segment from it, checks the key parses as an RSA key
        of a usable size, and stores them as sub_v2raytun_key and
        sub_v2raytun_scheme. From then on every subscription page renders the
        encrypted V2RayTun link instead of v2raytun://import/. Nothing is
        written when the download or the key check fails (502). Operators only;
        the same keys can be set by hand with PUT /api/settings/{key}.
      responses:
        "200":
          description: The key that was stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { type: string, description: "RSA public key in PEM form (sub_v2raytun_key)" }
                  fingerprint: { type: string, description: "SHA-256 of the key's DER encoding, hex" }
                  scheme: { type: string, description: "Encrypted link host segment, e.g. crypt (sub_v2raytun_scheme)" }
                  bits: { type: integer, description: "Key size in bits" }
        "502": { $ref: "#/components/responses/Error" }

  /api/sub-themes:
    get:
      tags: [settings]
      summary: Subscription page themes installed on disk
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  dir: { type: string, description: "The directory that was scanned (sub_theme_dir, or ./sub-themes)" }
                  themes:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, description: "Directory name; the value sub_theme stores" }
                        title: { type: string, description: "Literal <title> of the theme's entrypoint, when it has one" }
                        entry: { type: string, description: "index.html or sub.html" }

  /api/tunnels:
    get:
      parameters:
        - { $ref: "#/components/parameters/Limit" }
        - { $ref: "#/components/parameters/Offset" }
        - { $ref: "#/components/parameters/Q" }
        - { $ref: "#/components/parameters/UpdatedSince" }
      tags: [tunnels]
      summary: List tunnels
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Tunnel" } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [tunnels]
      summary: Create a tunnel
      description: >
        Links a relay node to one or more origin nodes. Both halves of the
        node configuration are derived from the row at sync time and never
        stored, so there is nothing to regenerate: editing the tunnel, or the
        address of a node it touches, changes what the next sync emits.


        An omitted `enabled` means enabled. `tls` and `transport` are completed
        on save — REALITY gets its key pair, plain TLS a self-signed certificate
        the far side pins — so a body may leave them empty and still produce a
        working tunnel. Two simultaneous creates of one name answer 409 to the
        loser; the name is unique and immutable.
      requestBody: { $ref: "#/components/requestBodies/Tunnel" }
      responses:
        "200": { $ref: "#/components/responses/Tunnel" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /api/tunnels/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [tunnels]
      summary: Update a tunnel
      description: >
        A partial update: the body is applied over the stored row, so a field it
        does not mention keeps its current value. `originIds` replaces the whole
        origin set when present. The name cannot be changed — it is the tag both
        halves carry and the namespace every credential is derived in — and a
        body carrying a different one is refused.
      requestBody: { $ref: "#/components/requestBodies/Tunnel" }
      responses:
        "200": { $ref: "#/components/responses/Tunnel" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [tunnels]
      summary: Delete a tunnel
      description: >
        Removes both halves from both nodes' configuration on the next sync.
      responses:
        "200": { description: Deleted }
        "404": { $ref: "#/components/responses/Error" }
  /api/tunnels/{id}/status:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [tunnels]
      summary: Live tunnel health
      description: >
        Asks both nodes what the tunnel is actually doing. A tunnel's health
        cannot be read from its configuration: a half dialing into a black hole
        and one carrying every user's traffic have identical config hashes.
        Every half is asked in parallel with its own 15-second budget, so the
        call is bounded at about 15 seconds however many origins the tunnel
        has.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TunnelStatus" }
        "404": { $ref: "#/components/responses/Error" }

  /api/tunnels/{id}/reset:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [tunnels]
      summary: Rebuild both halves of a tunnel live
      description: >
        Removes the tunnel's resource from every node that runs a half of it and
        adds it straight back, which drops the links under it and restarts the
        dial loop from its one-second floor. It is the one operation a tunnel
        needs that a config push cannot do: a wedged half — links up as far as
        the node is concerned but carrying nothing, or a dialer parked at the
        one-minute backoff ceiling behind a block that has since been lifted —
        is byte-for-byte the configuration the panel wants, so the diff-based
        sync finds nothing to do. Nothing else on the node is touched, and the
        documents are rebuilt from the database rather than read back from the
        node, so a reset also repairs a half that had drifted. Each node is
        asked in parallel with its own 15-second budget and reported on its own:
        a reset that fixed the relay and failed on one origin answers 200 with
        that origin marked, because one verdict for the tunnel would hide which
        half is still wedged.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TunnelReset" }
        "404": { $ref: "#/components/responses/Error" }
        "409":
          description: >
            The tunnel is disabled (no half is served to reset), or its relay
            node no longer exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/tunnels/{id}/duplicate:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [tunnels]
      summary: Duplicate a tunnel onto another relay
      description: >
        A fleet of relays reaching the same origins is a tunnel per relay, not
        one tunnel with several: that way a credential lifted off one relay opens
        only that relay, and adding a relay is a new row rather than an edit that
        rewrites the tunnels already carrying traffic. This is how such a row is
        made — the profiles, the origins, the mode and the carrying settings are
        copied, because they are what an operator would otherwise retype for every
        relay.


        What is not copied is the key material: the certificate and its private
        key, or the REALITY key pair and short id. They are generated again for
        the copy, which is what keeps the relays isolated from each other. A
        REALITY handshake target and a server name the operator typed are choices
        rather than credentials and do survive.


        `name` is required and must be free; `relayNodeId` defaults to the
        source's relay, which is only useful when the profiles are then given
        different ports. Validated exactly as a create is, so a port already
        taken on the target relay is refused rather than written.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: The copy's name, which becomes its tag and the namespace its credentials derive in.
                relayNodeId:
                  type: integer
                  description: The relay to put the copy on. Omitted means the source's own relay.
      responses:
        "200": { $ref: "#/components/responses/Tunnel" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /api/tunnels/bulk:
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [tunnels]
      summary: One operation over several tunnels
      description: >
        What keeps a fleet of per-relay tunnels from drifting apart: the usual
        failure is the third relay's tunnel quietly missing the origin the other
        two gained. One request, one audit record and one push per node however
        many tunnels moved.


        Every row goes through the same validation and the same save path as a
        single edit, so an operation cannot write something the single-row route
        would have refused — and it is applied per row: a tunnel the validation
        refuses is reported in `problems` while the others still apply. `matched`
        is what the ids found, `affected` what actually changed (a row already in
        the requested state counts for neither), and `failed` the rows refused.


        The operation is named in the body rather than in the path because
        `/api/tunnels/bulk/{op}` and `/api/tunnels/{id}/reset` would both match
        `/api/tunnels/bulk/reset`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [op, ids]
              properties:
                op:
                  type: string
                  enum: [enable, disable, delete, edit]
                  description: >
                    `edit` writes whichever of `originIds`, `workers`,
                    `streamWindow` and `balance` the body carries, and at least
                    one is required. `originIds` replaces the whole origin set and
                    must name at least one node: a tunnel with no origin has no
                    second half. A row already in the state asked for counts
                    towards `matched` and not `affected`, and is not pushed to its
                    nodes again — the origin set is compared as a set, so the
                    order it arrives in says nothing.
                ids:
                  type: array
                  items: { type: integer }
                  description: >
                    The tunnels to act on. Required: a selection has to be typed,
                    so an empty or absent list is refused rather than read as
                    every tunnel. At most 200.
                originIds:
                  type: array
                  items: { type: integer }
                workers: { type: integer }
                streamWindow: { type: integer }
                balance: { type: string, enum: [balance, priority] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TunnelBulkResult" }
        "400": { $ref: "#/components/responses/Error" }

components:
  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: nx_session
      description: Session cookie set by POST /api/login.
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        A third-party API token (see POST /api/tokens), or an admin session
        token from POST /api/login. Tokens are additionally limited by their
        owner, role and scopes, and are rate limited per token.

  parameters:
    Id:
      name: id
      in: path
      required: true
      schema: { type: integer }
    ReportHours:
      name: hours
      in: query
      schema: { type: integer, default: 1 }
      description: "Preset window, counted back from now. Ignored when start/end are given"
    ReportStart:
      name: start
      in: query
      schema: { type: integer, format: int64 }
      description: "Window start (unix seconds); must be given with end"
    ReportEnd:
      name: end
      in: query
      schema: { type: integer, format: int64 }
      description: "Window end (unix seconds); must be given with start"
    ReportLimit:
      name: limit
      in: query
      schema: { type: integer, default: 50, maximum: 500 }
      description: "Rows to return, heaviest first. Values above the maximum are clamped, not refused"
    Limit:
      name: limit
      in: query
      description: "Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given."
      schema: { type: integer, minimum: 0, maximum: 1000 }
    Offset:
      name: offset
      in: query
      description: "Rows to skip before the page."
      schema: { type: integer, minimum: 0 }
    Q:
      name: q
      in: query
      description: "Case-insensitive substring search over the resource's text columns."
      schema: { type: string }
    UpdatedSince:
      name: updated_since
      in: query
      description: "Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens)."
      schema: { type: integer, format: int64, minimum: 0 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: "Replay-safe retry key; see the Idempotency note in the API description."
      schema: { type: string, maxLength: 255 }

  responses:
    ListEnvelope:
      description: >
        Under /api/v1, a list is returned as {items, total, limit, offset};
        under the unversioned /api the same call returns a bare array of the
        same objects. X-Total-Count carries the unpaginated total either way.
      content: { application/json: { schema: { $ref: "#/components/schemas/ListEnvelope" } } }
    Error:
      description: Error
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    User:
      description: A user
      content: { application/json: { schema: { $ref: "#/components/schemas/User" } } }
    Plan:
      description: A plan
      content: { application/json: { schema: { $ref: "#/components/schemas/Plan" } } }
    Inbound:
      description: An inbound
      content: { application/json: { schema: { $ref: "#/components/schemas/Inbound" } } }
    Template:
      description: A template
      content: { application/json: { schema: { $ref: "#/components/schemas/Template" } } }
    Node:
      description: A node
      content: { application/json: { schema: { $ref: "#/components/schemas/Node" } } }
    Tunnel:
      description: A tunnel
      content: { application/json: { schema: { $ref: "#/components/schemas/Tunnel" } } }

  requestBodies:
    User:
      required: true
      content: { application/json: { schema: { $ref: "#/components/schemas/User" } } }
    Plan:
      required: true
      content: { application/json: { schema: { $ref: "#/components/schemas/Plan" } } }
    Inbound:
      required: true
      content: { application/json: { schema: { $ref: "#/components/schemas/Inbound" } } }
    Template:
      required: true
      content: { application/json: { schema: { $ref: "#/components/schemas/Template" } } }
    Node:
      required: true
      content: { application/json: { schema: { $ref: "#/components/schemas/Node" } } }
    Tunnel:
      required: true
      content: { application/json: { schema: { $ref: "#/components/schemas/Tunnel" } } }

  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
        inProgress: { type: boolean, description: "On a 409 only: the first request under this Idempotency-Key is still running. Retry with the same key." }
    Tunnel:
      type: object
      description: >
        A link between two nodes: traffic arriving at the relay leaves from an
        origin. It is its own collection rather than a pool item because it is
        the only resource that is inherently about a pair of nodes.
      properties:
        id: { type: integer }
        name:
          type: string
          description: >
            Unique. The config tag is tunnel-<slug of the name>, where the slug
            lowercases the name, turns spaces and punctuation into hyphens and
            drops everything else; a name left with nothing usable (one written
            in a non-Latin script, say) falls back to the row id. Two names that
            slug the same are refused. The name cannot be changed after
            creation: it is the tag both halves carry and the namespace their
            tokens are derived in.
        tag: { type: string, readOnly: true, description: "The tag route rules address on the relay." }
        mode:
          type: string
          enum: [reverse, direct]
          description: >
            reverse — the relay listens and origins dial in, so an origin behind
            NAT or with every port firewalled still works and users never face
            it. direct — the relay dials out to origins that listen.
        relayNodeId: { type: integer, description: "Where users connect." }
        relayName: { type: string, readOnly: true }
        originIds:
          type: array
          items: { type: integer }
          description: >
            Where traffic leaves for the internet. Several share one tag, and a
            user sticks to whichever one they hash to.
        originNames: { type: array, readOnly: true, items: { type: string } }
        profiles:
          type: array
          minItems: 1
          maxItems: 8
          items: { $ref: "#/components/schemas/TunnelProfile" }
          description: >
            The ways this tunnel is carried. Several profiles are several ways
            onto the same tunnel, not several tunnels: their links land in one
            session pool on the node under one peer identity, so a new stream
            takes whichever profile is least busy and a protocol that gets
            blocked costs throughput rather than the tunnel — while a user still
            sticks to the same origin, because what they are pinned to is the
            peer and every profile shares it.


            A request may instead use the older flat shape — listenPort with tls
            and transport beside it — which means exactly one profile and is
            translated. It edits the first profile rather than replacing the
            list, so a field it does not mention stays as it was; it cannot
            describe a second profile. Responses always carry profiles.
        balance:
          type: string
          enum: [balance, priority]
          default: balance
          description: >
            How a new stream chooses among the profiles that are up. `balance`
            spreads over every profile that has a link, taking the session with
            the fewest open streams — so profiles share the load in proportion
            to their worker counts, and one that gets blocked simply stops being
            chosen. `priority` keeps to the first profile that has any link at
            all and uses the next only when it has none, which is a standby path
            rather than extra capacity; the order is the order of the profiles
            list. Neither mode decides which *origin* a user reaches: that is
            rendezvous hashing on the peer name, so a blocked protocol costs
            throughput and never a user's exit IP.
        workers: { type: integer, description: "Parallel links per origin for a profile that sets none of its own, each with its own congestion window." }
        streamWindow: { type: integer, description: "Per-stream receive window in bytes; 0 takes the node default." }
        enabled: { type: boolean }
        ownsRelayFinal:
          type: boolean
          readOnly: true
          description: >
            Whether the relay's default outbound (route.final) is this tunnel.
            The panel derives it per node rather than taking it from a route,
            because a route lives on a template shared with nodes that do not
            carry the tag and the core resolves final eagerly at start — a
            template final naming a tunnel stops the core of every node that is
            not the relay. It beats the template's final, unless the relay's
            template has a rule sending traffic to the tunnel itself.
        finalReason:
          type: string
          readOnly: true
          enum: [disabled, noOrigin, shared, steered]
          description: >
            Why the tunnel is not the relay's default outbound, absent when it
            is. disabled/noOrigin — the tunnel produces no half at all.
            shared — the relay relays more than one tunnel, so which traffic
            goes down which is a choice the panel does not make; write route
            rules. steered — the relay's template routes some traffic to the
            tunnel itself, so its own final stands for the rest; the supported
            way to keep a tunnel for selected traffic only.
        createdAt: { type: integer }
        updatedAt: { type: integer }
    TunnelReset:
      type: object
      properties:
        tag: { type: string }
        nodes:
          type: array
          description: One entry per node that runs a half, relay first.
          items:
            type: object
            properties:
              nodeId: { type: integer }
              nodeName: { type: string }
              relay: { type: boolean, description: "True for the tunnel's relay, false for an origin." }
              ok: { type: boolean }
              error: { type: string, description: "Why this node's half was not rebuilt." }
    TunnelBulkResult:
      type: object
      properties:
        op: { type: string }
        matched: { type: integer, description: "How many of the ids named a tunnel that exists." }
        affected: { type: integer, description: "How many tunnels actually changed." }
        failed: { type: integer, description: "How many were refused by the validation." }
        error: { type: string, description: "The first refusal, since they are usually the same one repeated." }
        problems:
          type: array
          items: { type: string }
          description: Up to ten refusals, each naming the tunnel it belongs to.
    TunnelProfile:
      type: object
      properties:
        listenPort:
          type: integer
          description: >
            The port this profile binds on whichever side listens. It belongs to
            the tunnel, not to any inbound, so it must not collide with one — nor
            with another profile, which would be a bind failure naming an address
            rather than a tunnel.
        tls:
          allOf:
            - $ref: "#/components/schemas/JSON"
          description: >
            This profile's listener TLS block, in an inbound's shape. The dialing
            side's is derived from it, so the two cannot disagree. Missing key
            material is completed on save: REALITY gets a key pair, short ids and
            a handshake target; plain TLS gets a self-signed certificate for the
            tunnel's derived server name, which the dialing side pins. Every
            profile is issued for that same name — the certificate identifies the
            tunnel, not the port it was reached on.
        transport:
          allOf:
            - $ref: "#/components/schemas/JSON"
          description: >
            An optional v2ray transport carried by both halves of this profile,
            so the link reads as a web request rather than a long-lived stream.
            type must be one of ws, grpc, http, httpupgrade, quic; quic
            additionally requires TLS, and cannot carry REALITY.
        workers:
          type: integer
          description: >
            How many links this profile keeps per origin, and so how much of the
            load it takes: streams spread over links, so six against two is three
            times the share. 0 takes the tunnel's own workers.
    TunnelStatus:
      type: object
      properties:
        tag: { type: string }
        healthy: { type: boolean, description: "The relay has at least one link attached." }
        sessions: { type: integer }
        relay: { $ref: "#/components/schemas/TunnelHalfStatus" }
        origins: { type: array, items: { $ref: "#/components/schemas/TunnelHalfStatus" } }
    TunnelHalfStatus:
      type: object
      properties:
        nodeId: { type: integer }
        nodeName: { type: string }
        reachable: { type: boolean }
        error: { type: string }
        sessions:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              streams: { type: integer }
              uptime: { type: string }
        peers:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              server:
                type: string
                description: >
                  The address this entry dials, which is what tells two profiles
                  of one peer apart: profiles share a name deliberately — it is
                  the identity a user is pinned to — so without it a tunnel
                  carried over two ports reports two rows that look like one
                  thing said twice.
              live: { type: integer }
              want: { type: integer }
              backoff: { type: string }
              lastError: { type: string }
    JSON:
      description: An arbitrary JSON value (a core config fragment, etc.).
      nullable: true
    SetupState:
      type: object
      description: >
        What the first-run wizard needs to render itself. Every proposed default
        is a complete answer, so the form can be accepted as it stands.
      properties:
        required:
          type: boolean
          description: False on a configured panel; the wizard then does nothing.
        version: { type: string }
        defaults:
          type: object
          properties:
            username: { type: string }
            listenIp: { type: string }
            listenPort: { type: string }
            listenPinned:
              type: boolean
              description: >
                True when the bind address is pinned by -listen or
                NX_WEB_LISTEN (the container case). The listen fields in the
                submission are then ignored in favour of that address, so a
                client should present them as read-only rather than offering a
                port the panel will never move to.
            basepath:
              type: string
              description: A freshly generated random path, proposed per request.
            subBasepath: { type: string }
            tlsMode: { type: string, enum: [self_signed, none] }
        hostAddresses:
          type: array
          items: { type: string }
          description: >
            Every address found on this machine, publicly routable ones first —
            the pool the operator picks certificate addresses from.
        suggestedSans:
          type: array
          items: { type: string }
          description: >
            The subset worth naming on a certificate unprompted: the publicly
            routable ones. Inside a container the rest is bridge addressing no
            client can reach, and behind NAT the address clients use is on no
            interface here at all — so the wizard also offers the address the
            browser reached it at, and the operator edits the result.
    SetupRequest:
      type: object
      required: [token, username, password, listenPort, tlsMode]
      properties:
        token:
          type: string
          description: The one-time setup token printed by the installer.
        username: { type: string, minLength: 3, maxLength: 32 }
        password:
          type: string
          description: >
            At least 12 characters mixing three of lowercase, uppercase, digits
            and symbols.
        listenIp:
          type: string
          description: An IP literal, or empty to bind every interface.
        listenPort: { type: string }
        basepath:
          type: string
          description: URI prefix the panel is served under; empty serves at the root.
        subBasepath:
          type: string
          description: Path subscription links are built on; must differ from basepath.
        tlsMode:
          type: string
          enum: [self_signed, none]
          description: >
            self_signed makes the panel issue and renew its own certificate;
            none is plain HTTP, chosen deliberately.
        certSans:
          type: array
          items: { type: string }
          description: >
            IP addresses the generated certificate should name, taken verbatim.
            A hostname here is a 400: names belong in domain/subDomain, which the
            panel adds automatically and keeps the certificate in step with
            afterwards. Empty falls back to the address the wizard was reached at
            plus this machine's publicly routable addresses.
        domain: { type: string }
        subDomain: { type: string }
        timezone: { type: string }
    Me:
      type: object
      description: >
        The calling admin, returned by both login and /api/me. Resellers
        additionally carry their allowance — the reseller dashboard is built
        entirely from these fields. When the caller is a third-party API token,
        `token` is true, and `scopes` reports what the session may actually
        reach, so an integration can introspect its own limits instead of
        assuming them.

        `role` is the account's role name and `base` the tier behind it. Every
        permission decision is made on `base` and `scopes`: a custom role
        narrows a tier and can never climb out of one, so the name alone does
        not say what an account reaches.
      properties:
        id: { type: integer, description: "The operator this session acts as; 0 for a legacy unbound API token" }
        username: { type: string, description: "For an API token: \"token:\" followed by its description" }
        role: { type: string, description: "The account's role name. For the three built-in roles this is the tier's own name (sudo/admin/resale), which is what it always was; a custom role carries whatever the operator called it." }
        base: { $ref: "#/components/schemas/AdminRole" }
        owner: { type: boolean, readOnly: true, description: "Whether this account is the panel's owner. Absent for API-token sessions." }
        token: { type: boolean, readOnly: true, description: "True when the caller is an API token rather than a login. Absent otherwise." }
        totpEnabled: { type: boolean, readOnly: true, description: "Whether the account has two-factor authentication on. Absent for API tokens." }
        scopes: { type: array, items: { type: string }, readOnly: true, description: "What this session may reach: the role's scope set, intersected with the token's own when the caller is a token. Empty = unrestricted (a legacy token with no scopes)." }
        dashboardTiles:
          type: string
          nullable: true
          description: "Pinned dashboard tiles in order, comma-separated. null = never chosen (defaults apply), \"\" = nothing pinned. Absent for API-token sessions."
        timezone:
          type: string
          description: "Panel-wide IANA timezone the UI renders timestamps in (the `timezone` setting). Empty = the panel host's own zone."
        listenPinned:
          type: boolean
          readOnly: true
          description: >
            True when the bind address is pinned by -listen or NX_WEB_LISTEN
            (the container case). The web_listen_ip / web_listen_port settings
            are still writable but are ignored at every start, so a client
            should present them read-only rather than as an effective change.

        volume: { type: integer, format: int64, description: "Reseller: byte allowance per period, 0 = unlimited" }
        used: { type: integer, format: int64, description: "Reseller: traffic its users burned in the current period" }
        period: { $ref: "#/components/schemas/ResalePeriod" }
        periodDay: { type: integer, description: "Reseller: day the allowance resets on (0-6 weekly, day-of-month otherwise)" }
        periodStart: { type: integer, format: int64, description: "Reseller: unix start of the current period, 0 = no periodic reset" }
        expiry: { type: integer, format: int64, description: "Reseller: account expiry, unix seconds, 0 = never" }
        userLimit: { type: integer, description: "Reseller: max users it may create, 0 = unlimited" }
        users: { type: integer, description: "Reseller: how many users it currently owns" }
        blocked: { type: boolean, description: "Reseller: allowance spent or account expired — its users are held disabled" }
    AdminRole:
      type: string
      enum: [sudo, admin, resale]
      description: >
        The three tiers a role is based on, and the names of the three built-in
        roles. sudo = the main admin (full access, including admin management,
        tokens, and licensing); admin = a regular admin (operational routes
        only); resale = a reseller, which reaches nothing but the users it owns.

        A role narrows one of these; nothing widens one. Ownership is a separate
        mechanism: scopes decide which routes a session reaches, ownership
        decides which rows it sees.
    Role:
      type: object
      description: >
        A named permission set an operator account carries. A role is two things
        at once and they are not interchangeable: `base` is the tier it derives
        from — what the route guards ask about, and what decides whether the
        account owns a book of users — and `scopes` narrows, inside that tier,
        which routes its sessions reach. A role only ever narrows.

        The three built-in rows (`builtin: true`) are the three tiers. They are
        listed and their caps are editable, but their name, base and scope set
        come from the panel's own code and are rewritten on every start, so a
        panel upgrade that adds a permission reaches them without a migration —
        and they can neither be edited nor deleted through this API.
      properties:
        id: { type: integer, readOnly: true }
        name: { type: string, description: "Unique. The three built-in names are reserved. `admins.role` stores this value, so a rename carries its holders with it." }
        base: { $ref: "#/components/schemas/AdminRole" }
        builtin: { type: boolean, readOnly: true, description: "One of the three fixed roles: not editable, not deletable." }
        description: { type: string }
        scopes:
          type: array
          items: { type: string }
          description: >
            The granted permissions, from the list GET /api/scopes serves.
            At least one is required — an empty set would be a role that reaches
            nothing, which nobody means to create. A role cannot grant a scope
            the caller does not hold itself (403), or the first custom role is a
            way to become a main admin.
        userLimit: { type: integer, description: "Max users an account on this role may own, 0 = no cap. A ceiling over the per-account allowance, never a grant: the effective cap is the smaller of the two non-zero values, and creating past it answers 402. Only bites where ownership exists — a resale-based role." }
        maxDeviceLimit: { type: integer, description: "Ceiling on the per-user device (HWID) limit this role may hand out, 0 = no cap. With a cap set, a user carrying no device limit at all is refused rather than clamped: 0 means unlimited everywhere in this schema." }
        maxDuration: { type: integer, format: int64, description: "Ceiling in seconds on a duration-based plan this role may hand out (the window a user may sit in before its first connection starts the clock), 0 = no cap. Same refusal as maxDeviceLimit for an uncapped value." }
        admins: { type: integer, readOnly: true, description: "How many accounts carry this role" }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    ResalePeriod:
      type: string
      enum: ["", weekly, monthly, yearly]
      description: How often a reseller's volume allowance resets ("" = never).
    Admin:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        username: { type: string }
        role: { type: string, description: "The role this account carries, by name: one of the three built-ins, or a custom role. An account cannot be put on a role granting more than the caller holds itself (403)." }
        volume: { type: integer, format: int64, description: "Reseller allowance in bytes, 0 = unlimited (ignored for other roles)" }
        period: { $ref: "#/components/schemas/ResalePeriod" }
        periodDay: { type: integer, description: "Day the allowance resets on (0-6 weekly, day-of-month monthly/yearly)" }
        expiry: { type: integer, format: int64, description: "Reseller account expiry, unix seconds, 0 = never" }
        userLimit: { type: integer, description: "Max users the reseller may create, 0 = unlimited" }
        usersReadOnly: { type: boolean, description: "Reseller: refuse PUT and DELETE on its users. Creating is unaffected. Phrased as a restriction so an absent field restricts nothing." }
        plansOnly: { type: boolean, description: "Reseller: creating a user requires a planId from planIds and the plan's limits are applied server-side; an edit cannot change a limit at all. Requires at least one entry in planIds (400 otherwise)." }
        planIds: { type: array, items: { type: integer }, description: "Plans this reseller may sell (only meaningful with plansOnly). Absent on update leaves the selection alone." }
        templatesOnly: { type: boolean, description: "Reseller: its users are on the templates in templateIds and no other, so they reach only those templates' nodes. Membership is narrowed rather than refused wherever it is written — create, batch, edit, a change of owner — and `allTemplates` on such a user means every template of this reseller (stored as those templates, with `ownerTemplates`). Saving the reseller fits every user it owns: templates that left are removed at once, templates that joined reach its `ownerTemplates` users only. Requires at least one entry in templateIds (400 otherwise). Phrased as a restriction so an absent field restricts nothing." }
        templateIds: { type: array, items: { type: integer }, description: "Templates this reseller's users may be on (only meaningful with templatesOnly). Absent on update leaves the selection alone, and an empty list clears it; an id naming no template is 400." }
        subDomain: { type: string, description: "Reseller: which of the configured sub_domain entries this reseller's customers are published under (empty = the first; absent on update keeps the stored one). Answers 400 for a domain that is not configured. Every admin route is main-admin-only, which is what keeps a reseller from moving its own customers onto another reseller's domain. An assignment naming a domain later removed from the setting falls back to the first. Kept in the ingress as a canonical sub route bound to the account (GET /api/ingress); once the ingress holds more than the old address settings describe, a change answers 409 and is made there, while the value the account already has is accepted." }
        brandingId: { type: integer, description: "The branding this account's customers' subscription page and headers carry, and its own bound routes' admin panel when they name none (GET /api/brandings); absent on a write keeps the stored one, 0 clears it. 400 for one that does not exist" }
        used: { type: integer, format: int64, readOnly: true, description: "Reseller: traffic its users burned in the current period" }
        users: { type: integer, readOnly: true, description: "Operator or reseller: how many users it owns. 0 for a main admin, whose accounts are the panel's." }
        base: { type: string, readOnly: true, enum: [sudo, admin, resale], description: "The tier `role` resolves to, so a custom role based on `resale` reads as a reseller without the roles list." }
        lastLogin: { type: integer, format: int64, readOnly: true }
        lastLoginIp: { type: string, readOnly: true, description: "Address of the most recent successful login; the next login from elsewhere is recorded as admin.login_new_ip" }
        totpEnabledAt: { type: integer, format: int64, readOnly: true, description: "Unix seconds two-factor authentication was turned on; 0 = off. The seed itself is never exposed." }
        isOwner: { type: boolean, readOnly: true, description: "The panel's single owner: cannot be deleted, cannot be moved off the main-admin tier, and is the only account that may hand ownership over (POST /api/admins/{id}/owner). Exactly one account carries it." }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    MfaPending:
      type: object
      description: The password step of a two-factor login succeeded; finish at /api/login/mfa.
      properties:
        mfaRequired: { type: boolean, enum: [true] }
        pending: { type: string, description: "Opaque, five minutes, single use" }
    EventInfo:
      type: object
      description: >
        One entry of the event catalog. Four families: `user.*` (an account),
        `node.*` (a node), `admin.*` (an operator account) and `panel.*` (the
        install itself — never about an account, so never delivered to a
        subscriber bound to one). `scope` is the token scope a reader would
        need to fetch the objects the event names; `payload` is the shape of
        the envelope's `data`.
      properties:
        name: { type: string, example: user.created }
        family: { type: string, enum: [user, node, admin, panel] }
        scope: { type: string, example: "users:read", description: "\"\" when any token may read it" }
        description: { type: string }
        payload:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              type: { type: string, example: integer }
    EventSubscriberRequest:
      type: object
      required: [name, url]
      properties:
        name: { type: string, maxLength: 64 }
        url: { type: string, description: "Absolute http(s) URL; no credentials in it" }
        kind: { type: string, enum: [manual, addon], default: manual, description: "Create only; addon needs an API-token request or a tokenId" }
        tokenId: { type: integer, description: "Create only, kind addon from a session: the addon's API token (one carrying an addon id). An addon calling with its own token leaves it out" }
        events: { type: array, items: { type: string }, description: "Names from GET /api/events; empty = every event" }
        adminId: { type: integer, description: "0 = every event; otherwise only the events about this account" }
        enabled: { type: boolean, description: "Update only" }
        secret: { type: string, description: "Create only; generated when empty" }
    PanelService:
      type: object
      properties:
        key: { type: string, description: "The service's name: telegram, email, backup_s3, backup_sftp, backup_telegram" }
        enabled: { type: boolean }
        testable: { type: boolean, description: "Whether POST …/test does anything" }
        fields:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              secret: { type: boolean, description: "Written, never read back" }
              required: { type: boolean, description: "Must be set before the service can be switched on" }
              set: { type: boolean }
              value: { type: string, description: "Empty for a secret, whatever is stored" }
              options: { type: array, items: { type: string }, description: "The values a choice setting takes (email's security and certificate); absent for free text" }
              multiline: { type: boolean, description: "The value spans lines (a PEM key); absent otherwise" }
        lastRunAt: { type: integer, format: int64, description: "Unix seconds of the last run (a delivery, an upload, a login, a test); 0 = never" }
        lastOkAt: { type: integer, format: int64, description: "Unix seconds of the last successful run; 0 = never" }
        lastError: { type: string, description: "The last failure, cleared by the next success" }
    TelegramChat:
      type: object
      properties:
        id: { type: integer }
        chatId: { type: integer, format: int64 }
        title: { type: string, description: "The group's title or the person's name" }
        adminId: { type: integer, description: "The account the chat belongs to" }
        username: { type: string }
        lang: { type: string, enum: [en, fa, ru, zh] }
        pairedAt: { type: integer, format: int64 }
        subscriberId: { type: integer }
        events: { type: array, items: { type: string } }
        enabled: { type: boolean }
        failures: { type: integer }
        disabledAt: { type: integer, format: int64 }
    TelegramPairing:
      type: object
      properties:
        code: { type: string }
        adminId: { type: integer }
        lang: { type: string }
        events: { type: array, items: { type: string } }
        expiresAt: { type: integer, format: int64 }
        link: { type: string, description: "https://t.me/<bot>?start=<code>" }
        groupLink: { type: string, description: "https://t.me/<bot>?startgroup=<code>: Telegram adds the bot to a group the person picks and sends the code there as /start@<bot> <code>" }
        status: { type: string, enum: [pending, pairing, paired, expired] }
        chat: { $ref: "#/components/schemas/TelegramChat" }
        error: { type: string, description: "The poll's last failure while still pending" }
    SSOButton:
      type: object
      properties:
        id: { type: integer }
        kind: { type: string, description: "google, microsoft, github, gitlab, keycloak, authentik, okta, auth0, oidc, oauth2" }
        name: { type: string, description: "The button's name; empty = the kind's" }
    SSOProvider:
      type: object
      properties:
        id: { type: integer }
        kind: { type: string }
        name: { type: string }
        enabled: { type: boolean, description: "On the login page" }
        sortOrder: { type: integer }
        fields:
          type: array
          description: "The kind's settings; a secret is reported only as set"
          items:
            type: object
            properties:
              key: { type: string }
              secret: { type: boolean }
              required: { type: boolean }
              set: { type: boolean }
              value: { type: string }
        lastRunAt: { type: integer, format: int64 }
        lastOkAt: { type: integer, format: int64 }
        lastError: { type: string }
        links: { type: integer, description: "Identities linked through it" }
    SSOProviderWrite:
      type: object
      properties:
        kind: { type: string, description: "Create only" }
        name: { type: string }
        enabled: { type: boolean }
        sortOrder: { type: integer }
        config: { type: object, additionalProperties: { type: string } }
    SSOLink:
      type: object
      properties:
        id: { type: integer }
        adminId: { type: integer }
        username: { type: string, description: "On the main admin's list only" }
        providerId: { type: integer }
        providerKind: { type: string }
        providerName: { type: string }
        issuer: { type: string }
        label: { type: string, description: "The email or username the provider showed when it was linked; display only" }
        createdAt: { type: integer, format: int64 }
        lastLoginAt: { type: integer, format: int64, description: "0 = never used to sign in" }
    EmailRecipient:
      type: object
      properties:
        id: { type: integer }
        address: { type: string }
        adminId: { type: integer, description: "The account the address belongs to" }
        username: { type: string }
        lang: { type: string, enum: [en, fa, ru, zh] }
        addedAt: { type: integer, format: int64 }
        subscriberId: { type: integer }
        events: { type: array, items: { type: string } }
        enabled: { type: boolean }
        failures: { type: integer }
        disabledAt: { type: integer, format: int64 }
    EmailPending:
      type: object
      properties:
        id: { type: string, description: "Names this confirmation in …/confirm" }
        adminId: { type: integer }
        address: { type: string }
        lang: { type: string }
        events: { type: array, items: { type: string } }
        expiresAt: { type: integer, format: int64 }
    EmailRecipientUpdate:
      type: object
      properties:
        events: { type: array, items: { type: string }, description: "Names from GET /api/events; empty = every event the account may read" }
        enabled: { type: boolean }
        lang: { type: string, enum: [en, fa, ru, zh] }
    PanelServiceUpdate:
      type: object
      properties:
        enabled: { type: boolean, description: "Left out = unchanged" }
        config:
          type: object
          additionalProperties: { type: string }
          description: 'Settings to change; "" clears one'
    EventSubscriber:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        kind: { type: string, enum: [manual, addon] }
        name: { type: string }
        url: { type: string }
        events: { type: array, items: { type: string } }
        adminId: { type: integer }
        tokenId: { type: integer, readOnly: true, description: "The API token the addon subscriber belongs to (registered through it, or named by the main admin); 0 for a manual entry" }
        enabled: { type: boolean }
        signed: { type: boolean, readOnly: true, description: "Whether a secret is set; the migrated legacy webhook has none" }
        failures: { type: integer, readOnly: true, description: "Consecutive failed deliveries" }
        failingSince: { type: integer, format: int64, readOnly: true, description: "Unix seconds the current failure streak began, 0 when none" }
        disabledAt: { type: integer, format: int64, readOnly: true }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
        secret: { type: string, readOnly: true, description: "Present in the create and rotate responses only" }
        managedBy: { $ref: "#/components/schemas/AddonRef" }
    EventDelivery:
      type: object
      properties:
        id: { type: integer, format: int64, readOnly: true, description: "The X-Nx-Delivery value" }
        outboxId: { type: integer, format: int64, readOnly: true, description: "The event's id (the envelope's `id`)" }
        event: { type: string }
        status: { type: string, enum: [pending, delivered, dead] }
        attempt: { type: integer }
        statusCode: { type: integer, description: "The receiver's last answer; 0 when none came back" }
        error: { type: string }
        createdAt: { type: integer, format: int64 }
        nextAt: { type: integer, format: int64, description: "When the next attempt is due; 0 when none" }
        deliveredAt: { type: integer, format: int64 }
    AdminSession:
      type: object
      description: One login of an admin account. The token itself is never returned or stored — only its hash is.
      properties:
        id: { type: integer, readOnly: true }
        adminId: { type: integer, readOnly: true }
        ip: { type: string, readOnly: true, description: "Address the login came from, as the panel saw it" }
        userAgent: { type: string, readOnly: true, description: "Browser or client that logged in; capped, control characters stripped" }
        remember: { type: boolean, readOnly: true, description: "A \"remember me\" login (30-day sliding lifetime instead of 24 hours)" }
        createdAt: { type: integer, format: int64, readOnly: true }
        lastSeenAt: { type: integer, format: int64, readOnly: true, description: "Last activity, refreshed at most once a minute" }
        expiresAt: { type: integer, format: int64, readOnly: true, description: "When the session lapses if unused; slides with lastSeenAt" }
        current: { type: boolean, readOnly: true, description: "The session behind the request that produced this listing" }
    UserPatch:
      type: object
      minProperties: 1
      description: >
        The fields to change, by their names on `User`. Any of: `name`,
        `enable`, `config`, `desc`, `group`, `contact`, `adminId`, `subId`,
        `remark`, `volume`, `expiry`, `duration`, `activatedAt`, `nextReset`,
        `speedLimit`, `ipLimit`, `deviceLimit`, `autoReset`, `resetDays`,
        `resetPeriod`, `resetStartDay`, `resetStartMonth`, `resetCalendar`,
        `allTemplates`, `ownerTemplates`, `templateIds` — plus `version`. `config` and `contact`
        are replaced as a whole when sent.
      properties:
        version: { type: integer, format: int64, description: "The `updatedAt` the caller read; the edit is refused with 409 when the account has moved on." }
        name: { type: string }
        enable: { type: boolean }
        config: { $ref: "#/components/schemas/JSON" }
        desc: { type: string }
        group: { type: string }
        contact: { type: object, additionalProperties: { type: string } }
        adminId: { type: integer }
        subId: { type: string }
        remark: { type: string }
        volume: { type: integer, format: int64 }
        expiry: { type: integer, format: int64 }
        duration: { type: integer, format: int64 }
        activatedAt: { type: integer, format: int64 }
        nextReset: { type: integer, format: int64 }
        speedLimit: { type: integer, format: int64 }
        ipLimit: { type: integer }
        deviceLimit: { type: integer }
        autoReset: { type: boolean }
        resetDays: { type: integer }
        resetPeriod: { type: string }
        resetStartDay: { type: integer }
        resetStartMonth: { type: integer }
        resetCalendar: { type: string }
        allTemplates: { type: boolean }
        ownerTemplates: { type: boolean }
        templateIds: { type: array, items: { type: integer } }
        configFilter: { allOf: [ { $ref: "#/components/schemas/ConfigFilter" } ], nullable: true, description: "Replaces the filter; `null` lifts it." }
      additionalProperties: false
    LicenseView:
      type: object
      description: >
        The licence status at the top level — `licensed`, `reason`, `hwid`
        (this host's hardware id, which a licence is issued to), `issuedTo`,
        `licenseId`, `expiresAt`, `limits`, `verifiedAt`, `unverifiedAt` and
        the rest the licence page reads — plus the four below.
      additionalProperties: true
      properties:
        usage: { type: array, items: { type: object, additionalProperties: true }, description: "Each capped resource: what is used and what the licence allows." }
        free: { type: object, additionalProperties: true, description: "The free tier's caps: what an expiry falls back to." }
        checkout: { type: object, additionalProperties: true, description: "A purchase in progress, with the address to finish it." }
        recheck: { type: string, enum: [verified, unverifiable, unreachable, none], description: "Only on `POST /api/license/recheck`." }
    ConfigFilter:
      type: object
      description: >
        Which of the inbounds and endpoints its templates carry the account is
        served on. The templates still decide the nodes; this decides what the
        account is put on there — in the user lists a node is sent, the
        subscription and the client configs alike. A row is allowed when it
        matches **every** rule of its section; a section with no rules
        restricts nothing, and a filter with no rules at all is stored as none
        (`null`). A rule naming a tag on none of the account's templates is
        accepted and does nothing.
      properties:
        inbounds: { type: array, maxItems: 32, items: { $ref: "#/components/schemas/ConfigRule" } }
        endpoints: { type: array, maxItems: 32, items: { $ref: "#/components/schemas/ConfigRule" }, description: "Fields `protocol` and `tag` only." }
    ConfigFilterPreviewRow:
      type: object
      required: [id, tag, type, allowed]
      properties:
        id: { type: integer }
        tag: { type: string }
        type: { type: string }
        security: { type: string, description: "Inbounds only" }
        transport: { type: string, description: "Inbounds only" }
        allowed: { type: boolean }
    ConfigRule:
      type: object
      required: [field, op]
      properties:
        field: { type: string, enum: [protocol, tag, security, transport], description: "`protocol` is the row's type and `tag` its tag. Inbounds only: `security` (`none`, `tls`, `reality`) and `transport` (the transport's type, `none` when the inbound names none)." }
        op: { type: string, enum: [eq, neq, in, notIn, prefix, suffix, contains], description: "`eq`/`neq` read `value`, `in`/`notIn` read `values`, and the three text operators read `value` ignoring case. `security` takes no text operator." }
        value: { type: string, maxLength: 256 }
        values: { type: array, maxItems: 64, items: { type: string, maxLength: 256 } }
    User:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        name: { type: string }
        enable:
          type: boolean
          description: "Absent on a create is on; on an edit (a full replace) absent is off. A person's off — this field set to false, or the bulk disable — is never undone by the quota enforcer; only a person turns it back on."
        disabledReason:
          type: string
          readOnly: true
          description: "Why the account is off: `manual` (a person), or the enforcer's own reason — `expiry`, `volume`, `resale-volume`, `resale-expiry` — which it lifts when the account is back within its limits. Empty while the account is on, and on accounts switched off before the field existed."
        config: { $ref: "#/components/schemas/JSON" }
        desc: { type: string }
        contact:
          type: object
          additionalProperties: { type: string }
          description: >
            The customer's own details, as one flat object of string pairs.
            Six keys are standard — `telegram_id`, `bale_id` and `soroush_id`
            (a bot's numeric chat id or an @username), `rubika_id` (a Rubika
            bot's chat id), `email` and `phone` — and are the ones the panel
            itself reads: they are filtered on (`contact_<key>`), matched by
            the free-text `q`, exported as their own CSV columns and written by
            nx-migrate. Any other pair is the operator's own: stored and
            returned untouched, and interpreted by nothing.
            Values may be sent as JSON numbers or booleans and come back as
            text; an empty value removes the key, and a document left with no
            pairs is stored as absent. At most 24 pairs, 48 characters per key,
            256 per value and 2 KiB in all; anything else is a 400, as is a
            standard key whose value is not what it claims to be.
            An addon **reads** this and keeps its own state in its own
            database — a whole-object PUT on a user is last-writer-wins, and
            nothing arbitrates it.
        subToken: { type: string }
        subId: { type: string, description: "Subscription identifier used in /sub/{id}; defaulted random on create" }
        remark: { type: string, description: "Label prepended to generated share-link names" }
        volume: { type: integer, format: int64, description: "Byte cap, 0 = unlimited" }
        speedLimit: { type: integer, format: int64, description: "Throughput cap in bytes per second, per direction and per node, 0 = unlimited" }
        ipLimit: { type: integer, description: "Distinct source addresses the user may hold at once across every node (IPv6 counted by /64), 0 = unlimited" }
        deviceLimit: { type: integer, description: "Distinct devices that may hold the subscription, counted by the `x-hwid` a compatible client sends when it fetches; a device past the cap is refused the configs (403). Clients that send no id are served unless the device_limit_strict setting is on. 0 = unlimited." }
        expiry: { type: integer, format: int64, description: "Unix seconds, 0 = never. With `duration` set and `activatedAt` still 0 this is provisional and moves to the real date when the plan starts." }
        duration: { type: integer, format: int64, description: "Plan length in seconds, counted from the user's first connection instead of from creation. 0 = a fixed `expiry` date. Setting it (or changing it on an existing user) re-arms the plan: `activatedAt` returns to 0 and the countdown waits for the next connection." }
        activatedAt: { type: integer, format: int64, description: "Unix seconds of the first traffic that started the plan; 0 while it has not started. Read-only: the panel sets it.", readOnly: true }
        up: { type: integer, format: int64 }
        down: { type: integer, format: int64 }
        onlineAt: { type: integer, format: int64, description: "Unix ts of last traffic; online if within ~90s", readOnly: true }
        subFetchedAt: { type: integer, format: int64, readOnly: true, description: "Unix seconds the subscription body was last handed to a client; 0 = never. The only signal that a client holds the configs." }
        subSeenAt: { type: integer, format: int64, readOnly: true, description: "Unix seconds of the last request of any kind against the subscription URL — the page and its polling included; 0 = never" }
        subClient: { type: string, readOnly: true, description: "User-Agent of the last client to take the body (truncated, control characters stripped). Untrusted text." }
        subFetchIp: { type: string, readOnly: true, description: "Address the body was last fetched from" }
        lastInbound: { type: string, readOnly: true, description: "Tag of the inbound this user's most recent connection arrived on, as the node reported it — the one-word answer to which protocol the user actually uses. Empty until the first drain that carried it." }
        autoReset: { type: boolean, description: "Switch the periodic traffic reset on. What kind of cycle it is depends on `resetPeriod`." }
        resetDays: { type: integer, description: "Rolling cycle length in days, read only when `resetPeriod` is empty." }
        resetPeriod: { type: string, enum: ["", "weekly", "monthly", "yearly"], description: "Empty = the rolling cycle measured in `resetDays`. Set = a calendar cycle anchored to `resetStartDay` (and `resetStartMonth`, yearly only), which ignores `resetDays`. \"Monthly\" is a calendar month: a rolling 30 days walks about five days a year until a customer's reset lands mid-billing-month." }
        resetStartDay: { type: integer, description: "Anchor for a calendar cycle: weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. A day past the end of a month is clamped to its last day (the 31st is the 28th in February, the 30th of Esfand is the 29th in a common year) and returns to the anchor the month after." }
        resetStartMonth: { type: integer, minimum: 1, maximum: 12, description: "Anchor month, read for the yearly cycle only." }
        resetCalendar: { type: string, enum: ["", "jalali"], description: "Empty = Gregorian. `jalali` puts monthly and yearly boundaries on the Persian calendar the panel already displays for `fa`. Ignored for the weekly cycle, which is the same seven days in both, and cleared when the cycle is rolling." }
        nextReset: { type: integer, format: int64, description: "The pending appointment: the next boundary, or the rolling cycle's next due date. The panel writes it; a caller does not." }
        totalUp: { type: integer, format: int64 }
        totalDown: { type: integer, format: int64 }
        group: { type: string, description: "Free text label for bulk operations" }
        adminId: { type: integer, description: "The operator or reseller that owns this user; 0 = owned by the panel (the main admins). Absent on create = the caller's own; absent on an edit = unchanged. Only a main admin (or a token bound to nobody) may set it to another account: operators and resellers always create for themselves and cannot move an owner. Resellers see only their own users; operators see every user." }
        ownerName: { type: string, readOnly: true, description: "The username behind `adminId`, for the owner column. Computed per response; omitted for the panel's own accounts and in a reseller's responses." }
        allTemplates: { type: boolean, description: "Provision the user on every template (ignores templateIds). Under an owner limited to chosen templates (Admin.templatesOnly) it is stored as false with ownerTemplates set. On an edit that hands a user with ownerTemplates to an owner with no grant, it is read as that mark echoed back and the user keeps its templates; only a PATCH naming allTemplates alone widens it." }
        ownerTemplates: { type: boolean, description: "Every template the owner may use, on a user of a reseller limited to chosen templates: stored as those templates (templateIds), and a template added to the reseller's grant later is added here too. Sending it, or allTemplates, asks for the same thing; the panel sets whichever fits the owner. Absent on an edit (a PUT included) it keeps the stored value while the edit's templateIds are absent or the templates the user is on; a different templateIds is a choice of templates and clears it. A user handed with it to an owner with no grant — the panel, an operator — keeps the templates it is on rather than widening to every template." }
        templateIds: { type: array, items: { type: integer }, description: "Explicit template membership (used when allTemplates is false); absent on create keeps legacy all-templates behaviour. Narrowed to the owner's template grant when the owner is a restricted reseller." }
        configFilter: { allOf: [ { $ref: "#/components/schemas/ConfigFilter" } ], nullable: true, description: "Limits the account to some of its templates' inbounds and endpoints; `null` = no limit. Absent on an edit (a PUT included) = unchanged, so a client that never heard of it cannot lift it; `null` lifts it. For an account a plans-only reseller owns, a create stores none, whoever makes it (the plan sold carries no filter), and the reseller's own edits keep the stored one without reading what they send; a main admin's or an operator's edit sets it as for any account." }
        subUrl: { type: string, readOnly: true, description: "This account's absolute subscription URL, computed per response and never stored. It is returned because a wildcard sub_domain mints a hostname per subscriber, derived from the account, which no client of this API can compute — build links from this rather than from the sub_domain setting. Omitted where a response does not carry it." }
        licensed: { type: boolean, readOnly: true, description: "Whether the row is inside the licence cap: one of the first N users by id, N being the licensed count. A row outside it is listed and can be deleted, is served on no node, and answers 402 to every other write. Computed per response; the cap moves whenever a row is deleted or a licence installed." }
        planId: { type: integer, writeOnly: true, description: "Create only: apply this plan's limits over the rest of the body. Ignored on update — see PUT /api/users/{id}. Nothing links the user to the plan afterwards." }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    Plan:
      type: object
      description: >
        The limits a new user starts with. A plan is applied and forgotten:
        `POST /api/users` copies its values onto the new account and nothing
        links the two afterwards, so editing or deleting a plan never reaches
        an account already sold. Its `group` is the only trace it leaves, and
        the handle the users list filters by.
      properties:
        id: { type: integer, readOnly: true }
        name: { type: string }
        desc: { type: string }
        sortOrder: { type: integer, description: "Display order; equal values fall back to the id" }
        volume: { type: integer, format: int64, description: "Byte cap the account starts with, 0 = unlimited" }
        days: { type: integer, format: int64, description: "Plan length in days, 0 = never expires" }
        onFirstUse: { type: boolean, description: "true: `days` becomes the user's `duration`, so the clock starts at the first connection. false: a fixed `expiry`, counted from the moment the user is created." }
        speedLimit: { type: integer, format: int64, description: "Throughput cap in bytes per second, per direction, 0 = unlimited" }
        ipLimit: { type: integer, description: "Concurrent source addresses, 0 = unlimited" }
        deviceLimit: { type: integer, description: "Devices that may hold the subscription (by `x-hwid`), 0 = unlimited" }
        autoReset: { type: boolean, description: "Switch the periodic traffic reset on. What kind of cycle it is depends on `resetPeriod`." }
        resetDays: { type: integer, description: "Rolling cycle length in days, read only when `resetPeriod` is empty." }
        resetPeriod: { type: string, enum: ["", "weekly", "monthly", "yearly"], description: "Empty = the rolling cycle measured in `resetDays`. Set = a calendar cycle anchored to `resetStartDay` (and `resetStartMonth`, yearly only), which ignores `resetDays`. \"Monthly\" is a calendar month: a rolling 30 days walks about five days a year until a customer's reset lands mid-billing-month." }
        resetStartDay: { type: integer, description: "Anchor for a calendar cycle: weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. A day past the end of a month is clamped to its last day (the 31st is the 28th in February, the 30th of Esfand is the 29th in a common year) and returns to the anchor the month after." }
        resetStartMonth: { type: integer, minimum: 1, maximum: 12, description: "Anchor month, read for the yearly cycle only." }
        resetCalendar: { type: string, enum: ["", "jalali"], description: "Empty = Gregorian. `jalali` puts monthly and yearly boundaries on the Persian calendar the panel already displays for `fa`. Ignored for the weekly cycle, which is the same seven days in both, and cleared when the cycle is rolling." }
        allTemplates: { type: boolean, description: "Provision on every template (ignores templateIds)" }
        templateIds: { type: array, items: { type: integer }, description: "Explicit template membership, used when allTemplates is false" }
        group: { type: string, description: "Group label written onto every account this plan creates" }
        namePrefix: { type: string, description: "Prepended to the username the caller chose; empty = unchanged" }
        nameSuffix: { type: string, description: "Appended to the username the caller chose; empty = unchanged" }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    SubscriptionInfo:
      type: object
      description: The subscription page view model (the subscription page theme guide lists the template field names).
      properties:
        user:
          type: object
          properties:
            name: { type: string }
            remark: { type: string }
            desc: { type: string }
            group: { type: string }
            subId: { type: string }
            enable: { type: boolean }
            online: { type: boolean }
            onlineAt: { type: integer, format: int64 }
            status: { type: string, enum: [active, disabled, pending, expired, limited] }
        usage:
          type: object
          properties:
            up: { type: integer, format: int64 }
            down: { type: integer, format: int64 }
            used: { type: integer, format: int64 }
            total: { type: integer, format: int64, description: "0 = unlimited" }
            remaining: { type: integer, format: int64 }
            percent: { type: integer }
            unlimited: { type: boolean }
            usedText: { type: string }
            totalText: { type: string }
            remainingText: { type: string }
            totalUp: { type: integer, format: int64 }
            totalDown: { type: integer, format: int64 }
            lifetime: { type: integer, format: int64, description: "totalUp + totalDown; survives a periodic reset" }
            lifetimeText: { type: string }
        expire:
          type: object
          properties:
            at: { type: integer, format: int64, description: "Unix seconds, 0 = never" }
            never: { type: boolean }
            expired: { type: boolean }
            days: { type: integer }
            pending: { type: boolean, description: "The plan is measured from the first connection and has not had one, so `at` is provisional and `days` is the whole plan." }
            duration: { type: integer, format: int64, description: "Plan length in seconds, 0 = a fixed expiry date" }
        reset:
          type: object
          properties:
            auto: { type: boolean }
            days: { type: integer }
            next: { type: integer, format: int64 }
        configs:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              protocol: { type: string }
              server: { type: string, description: "Bare address — an IPv6 literal carries no brackets" }
              port: { type: integer }
              address: { type: string, description: "server and port joined for display, bracketed for IPv6 (e.g. [2001:db8::1]:443)" }
              network: { type: string }
              security: { type: string }
              link: { type: string, description: "Always empty on /info" }
        apps:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              type: { type: string }
              fileName: { type: string }
              content: { type: string, description: "Always empty on /info" }
        sub:
          type: object
          properties:
            url: { type: string }
            urls: { type: object, additionalProperties: { type: string } }
            updateInterval: { type: integer }
        panel:
          type: object
          properties:
            title: { type: string }
            supportUrl: { type: string }
            announce: { type: string }
            theme: { type: string }
            version: { type: string }
        assetBase: { type: string }
        lang: { type: string }
        dir: { type: string, enum: [ltr, rtl] }
        now: { type: integer, format: int64 }
        timezone: { type: string }
    Inbound:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        tag: { type: string }
        type: { type: string, description: "vless, vmess, trojan, shadowsocks, ..." }
        config: { $ref: "#/components/schemas/JSON" }
        remark: { type: string }
        useNodeCert: { type: boolean, description: "Replace this inbound's TLS cert with the node's managed cert" }
        certificateId: { type: integer, nullable: true, description: "Panel certificate assigned to this inbound's TLS (wins over useNodeCert)" }
        clientConfig: { $ref: "#/components/schemas/JSON", description: "Client-side overrides used only for link/subscription generation (never sent to nodes): server, server_port, and a tls client template" }
        frontedOnly:
          type: boolean
          description: >
            Drop this inbound's direct entries from a subscription when a domain
            front rendered for it. Applied per user and only when a front
            actually rendered for that user, so a front scoped to an origin node
            they cannot reach never takes the inbound out of their file.
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    Certificate:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        nodeId: { type: integer, readOnly: true }
        type: { type: string, enum: [self_signed, acme, custom] }
        sans: { type: array, items: { type: string }, description: "Domains and/or IPs" }
        challenge: { type: string, enum: ["", http-01, tls-alpn-01, dns-01] }
        certificate: { type: string, readOnly: true, description: "PEM certificate chain (public)" }
        client: { $ref: "#/components/schemas/JSON", description: "Client-side TLS template for links/subscriptions: server_name, insecure, alpn, utls.fingerprint, certificate_public_key_sha256" }
        publicKeySha256: { type: string, readOnly: true, description: "Hex SHA-256 of the leaf SubjectPublicKeyInfo (the client field certificate_public_key_sha256)" }
        certSha256: { type: string, readOnly: true, description: "Hex SHA-256 of the leaf DER (hysteria2 pinSHA256 / mihomo fingerprint)" }
        issuer: { type: string, readOnly: true }
        notBefore: { type: integer, format: int64, readOnly: true }
        notAfter: { type: integer, format: int64, readOnly: true }
        status: { type: string, enum: [active, pending, error], readOnly: true }
        message: { type: string, readOnly: true }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    CertRequest:
      type: object
      required: [type]
      properties:
        type: { type: string, enum: [self_signed, acme, custom] }
        sans: { type: array, items: { type: string }, description: "Domains and/or IPs (required except for custom, where they derive from the PEM)" }
        challenge: { type: string, enum: [http-01, tls-alpn-01, dns-01], description: "ACME only" }
        email: { type: string }
        dnsProvider: { type: string, enum: ["", cloudflare, alidns, acmedns], description: "dns-01 only; \"\" clears it" }
        dnsToken: { type: string, description: "dns-01 provider credential (multi-field split on ':')" }
        certificate: { type: string, description: "PEM certificate chain (custom type only)" }
        key: { type: string, description: "PEM private key (custom type only)" }
        client: { $ref: "#/components/schemas/JSON", description: "Client-side TLS template (see Certificate.client)" }
    PanelCertificate:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        panelWeb: { type: boolean, readOnly: true, description: "The panel's own HTTPS certificate (listed only): no addon on another server may fetch it" }
        name: { type: string }
        type: { type: string, enum: [self_signed, acme, custom] }
        sans: { type: array, items: { type: string }, description: "Domains and/or IPs" }
        challenge: { type: string, enum: ["", http-01, tls-alpn-01, dns-01] }
        email:
          type: string
          description: >
            ACME account contact. Echoed, so an edit form can round-trip it: an
            empty value on update clears it, unlike dnsToken.
        dnsProvider:
          type: string
          enum: ["", cloudflare, alidns, acmedns]
          description: >
            dns-01 provider. Echoed, so an edit form can round-trip it; required
            when the panel itself answers a dns-01 challenge.
        obtainNodeId:
          type: integer
          nullable: true
          description: >
            Node whose ACME agent performs the challenge (acme only). Null means
            the panel obtains the certificate itself.
        certificate: { type: string, readOnly: true, description: "PEM certificate chain (public)" }
        client: { $ref: "#/components/schemas/JSON", description: "Client-side TLS template for links/subscriptions (see Certificate.client)" }
        publicKeySha256: { type: string, readOnly: true }
        certSha256: { type: string, readOnly: true }
        issuer: { type: string, readOnly: true }
        notBefore: { type: integer, format: int64, readOnly: true }
        notAfter: { type: integer, format: int64, readOnly: true }
        status: { type: string, enum: [active, pending, error], readOnly: true }
        message: { type: string, readOnly: true }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    PanelCertRequest:
      type: object
      required: [name, type]
      properties:
        name: { type: string }
        type: { type: string, enum: [self_signed, acme, custom] }
        sans: { type: array, items: { type: string }, description: "Domains and/or IPs (required except for custom, where they derive from the PEM)" }
        challenge: { type: string, enum: [http-01, tls-alpn-01, dns-01], description: "ACME only" }
        email: { type: string }
        dnsProvider: { type: string, enum: ["", cloudflare, alidns, acmedns], description: "dns-01 only; \"\" clears it" }
        dnsToken: { type: string, description: "dns-01 provider credential; empty on update keeps the stored one" }
        obtainNodeId:
          type: integer
          nullable: true
          description: >
            Who answers the ACME challenge. Null (the default) means the panel
            itself: dns-01 works for any name, while http-01 and tls-alpn-01
            need the name to resolve to the panel and port 80/443 free on it —
            requesting either for an IP SAN is refused with 400. Set it to a
            node to have that node's agent answer instead, which is the only way
            to satisfy an on-host challenge for a name pointing at the node.
        certificate: { type: string, description: "PEM certificate chain (custom type only; empty on update keeps the stored pair)" }
        key: { type: string, description: "PEM private key (custom type only)" }
        client: { $ref: "#/components/schemas/JSON", description: "Client-side TLS template (never reissues the certificate)" }
    Template:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        name: { type: string }
        remark: { type: string }
        route: { $ref: "#/components/schemas/JSON" }
        core: { $ref: "#/components/schemas/JSON", description: "dns/log/ntp blocks" }
        inbounds: { type: array, items: { $ref: "#/components/schemas/Inbound" }, readOnly: true }
        outbounds: { type: array, items: { $ref: "#/components/schemas/Outbound" }, readOnly: true }
        endpoints: { type: array, items: { $ref: "#/components/schemas/Endpoint" }, readOnly: true }
        ruleSets: { type: array, items: { $ref: "#/components/schemas/RuleSet" }, readOnly: true }
        inboundIds: { type: array, items: { type: integer }, writeOnly: true }
        outboundIds: { type: array, items: { type: integer }, writeOnly: true }
        endpointIds: { type: array, items: { type: integer }, writeOnly: true }
        ruleSetIds: { type: array, items: { type: integer }, writeOnly: true }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    DomainFront:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        label: { type: string, description: "Names the front, and suffixes the entries it renders so two fronts on one inbound are told apart by something other than a trailing -2" }
        address: { type: string, description: "The hostname a client dials — the CDN's, not the node's" }
        port: { type: integer, nullable: true, description: "null means 443. A CDN's port is unrelated to the inbound's listen port, which is what the origin sees" }
        sni: { type: string }
        hostHeader: { type: string }
        alpn: { $ref: "#/components/schemas/JSON" }
        fingerprint: { type: string }
        allowInsecure: { type: boolean }
        clientIpHeader: { type: string, description: "The request header the CDN sets with the real client address (CF-Connecting-IP for Cloudflare). When named, the panel sets trusted_x_forwarded_for to it on every XHTTP inbound this front is enabled on whose operator left that field empty, and the node takes the client address from this header's value, when it is an IP address, on requests carrying it, not from X-Forwarded-For (whose first entry a CDN appends to rather than replaces, so it stays the client's claim). The address is as trustworthy as three things: the CDN writing the header itself rather than passing on a client's (Cloudflare does; Fastly passes a client's Fastly-Client-IP on unless configured not to); the node accepting connections from the CDN alone, since a direct client can send the header too; and every front on the inbound naming the same header, since the node believes the first listed header a request carries and a CDN passes on one it does not write. Empty means the inbound keeps its own value" }
        originNodeId:
          type: integer
          nullable: true
          description: >
            Which node this front lands on. NOT a fan-out dimension — the front
            still renders one entry per inbound. It is there so the panel can
            leave the front out of the file of a user who reaches no such node,
            since their credentials were never provisioned there. null means the
            operator's CDN decides and no check runs.
        enabled: { type: boolean }
        sort: { type: integer, description: "Order within the catalogue, which is the order the entries render in" }
        inbounds:
          type: array
          readOnly: true
          items: { $ref: "#/components/schemas/Inbound" }
        warnings:
          type: array
          nullable: true
          readOnly: true
          items: { type: string }
          description: >
            Cross-checks that are advisory rather than refusals, because the
            other half is routinely written afterwards: an ingress that does not
            route the SNI this front's traffic arrives with would send it to the
            fallback. The front is saved; these are not errors.
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    DomainFrontInput:
      type: object
      required: [label, address]
      properties:
        label: { type: string }
        address: { type: string }
        port: { type: integer, nullable: true }
        sni: { type: string }
        hostHeader: { type: string }
        alpn: { $ref: "#/components/schemas/JSON" }
        fingerprint: { type: string }
        allowInsecure: { type: boolean }
        clientIpHeader: { type: string, description: "The request header this CDN writes the client address into (CF-Connecting-IP for Cloudflare); see DomainFront. An RFC 9110 header name, anything else is a 400. Like the other text fields, absent or empty on an edit clears it." }
        originNodeId: { type: integer, nullable: true }
        enabled: { type: boolean, description: "Absent leaves it unchanged on edit, and defaults to true on create" }
        sort: { type: integer }
        inboundIds:
          type: array
          items: { type: integer }
          description: "Replaces which inbounds this front is enabled on. Absent leaves the membership alone on an edit; an explicit empty list detaches every inbound."
    Node:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        licensed: { type: boolean, readOnly: true, description: "Whether the node is inside the licence's node cap: one of the first N nodes by id. A node outside it is listed, is served an empty plan, and answers 402 to every write but delete. Computed per response." }
        name: { type: string }
        address: { type: string }
        port: { type: integer }
        status: { type: string, enum: [connecting, connected, error, disabled], readOnly: true }
        message: { type: string, readOnly: true, description: "Why the node is not working; empty while it is connected" }
        warnings:
          type: array
          nullable: true
          readOnly: true
          items: { type: string }
          description: "Config items the node is not running: those its core skipped on its last successful load, then those the last live sync could not apply (kept until a sync applies them). The node is serving everything else; these are not connection errors."
        remark: { type: string }
        linkAddress: { type: string, description: "The node's public address, or several: a comma-separated list whose entries are each a bare address or \"label|address\". The first is the primary — what endpoints and app configs use, and what a tunnel dials; every entry after it adds a copy of each client link. Empty falls back to address. Never sent to a node." }
        isLocal: { type: boolean }
        coreVersion: { type: string, readOnly: true }
        templateId: { type: integer, nullable: true }
        multiplier: { type: number, description: "Traffic multiplier applied to user usage on this node (0 treated as 1)" }
        enabled: { type: boolean, readOnly: true, description: "Admin on/off intent; toggled via /enabled" }
        limitBytes: { type: integer, format: int64, description: "Periodic usage cap in bytes (0 = no limit)" }
        limitPeriod: { type: string, enum: ["", weekly, monthly, yearly], description: "Periodic limit window" }
        limitStartDay: { type: integer, description: "Weekly 0-6 (Sun-Sat); monthly/yearly 1-31" }
        limitStartMonth: { type: integer, description: "Yearly anchor month 1-12" }
        limitDisabled: { type: boolean, readOnly: true, description: "Auto-disabled by the periodic usage limit" }
        periodUsed: { type: integer, format: int64, readOnly: true, description: "Raw bytes counted against limitBytes so far in the current period; 0 without an active limit" }
        periodStart: { type: integer, format: int64, readOnly: true, description: "Unix seconds the current limit period began; 0 without an active limit" }
        online:
          type: integer
          readOnly: true
          nullable: true
          description: >
            Distinct users that sent traffic through this node inside the online
            window (90s). Absent rather than 0 when the usage series is switched
            off — the difference between "nobody is connected" and "nothing is
            counting".
        live:
          readOnly: true
          nullable: true
          allOf: [ { $ref: "#/components/schemas/NodeLive" } ]
          description: >
            What the node said about itself on its last heartbeat. Absent for a
            node that has not answered since the panel started, has stopped
            answering, or predates the figures — "not reported", never a zero
            that reads as idle.
        fallbackCertMode: { type: string, enum: ["", node, panel], description: "Certificate substituted into TLS configs that end up without one" }
        fallbackCertId: { type: integer, nullable: true, description: "Panel certificate used when fallbackCertMode=panel" }
        up: { type: integer, format: int64, readOnly: true }
        down: { type: integer, format: int64, readOnly: true }
        sshHost: { type: string, description: "Host for the automatic installer; empty falls back to address" }
        sshPort: { type: integer }
        sshUser: { type: string }
        sshKeyId: { type: integer, nullable: true, description: "Panel SSH key used for this host; null = the default key" }
        sshKeyInstalled: { type: boolean, readOnly: true, description: "The panel's key is authorised on this host, so updates need no password" }
        setup: { type: string, readOnly: true, description: "Last-seen deployment: docker, podman, containerd, lxc or linux" }
        arch: { type: string, readOnly: true, description: "Last-seen GOARCH of the running agent" }
        nodeVersion: { type: string, readOnly: true, description: "Last-seen node agent version (coreVersion is the proxy engine's)" }
        infoSeenAt: { type: integer, format: int64, readOnly: true, description: "When the three fields above were last confirmed" }
        lastStatusChange: { type: integer, format: int64, readOnly: true }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    SSHCredentials:
      type: object
      description: >
        How the panel authenticates to a node's host. Nothing here is stored:
        a password lives as long as the connection it opened. With neither
        password nor privateKey the panel falls back to its own key, which
        works only where that key was previously authorised (installKey).
      properties:
        sshHost: { type: string, description: "Defaults to the node's sshHost, then its address" }
        sshPort: { type: integer, default: 22 }
        sshUser: { type: string, default: root }
        password: { type: string, format: password, writeOnly: true }
        privateKey: { type: string, writeOnly: true, description: "PEM private key" }
        passphrase: { type: string, format: password, writeOnly: true }
    InstallRequest:
      allOf:
        - $ref: "#/components/schemas/SSHCredentials"
        - type: object
          properties:
            method: { type: string, enum: [auto, script, docker], description: "auto keeps what the host already runs" }
            source:
              type: string
              enum: [upload, panel, github]
              description: >
                Where a script install's binary comes from. upload pushes this
                panel's staged binary down the SSH connection and needs nothing
                of the host's own connectivity; panel has the host download it
                over HTTP; github has it download a public release.
            version: { type: string, description: "Node release tag; only source=github and docker installs can honour one" }
            image: { type: string, description: "Docker image reference; defaults to ghcr.io/nx-panel/node:<version>" }
            listen: { type: string, description: "Control API listen address on the node" }
            panelUrl: { type: string, description: "The panel as this node can reach it; needed only by source=panel" }
            insecure: { type: boolean, description: "Do not verify the panel's TLS certificate when downloading from it" }
            installKey: { type: boolean, description: "Authorise the panel's SSH key on the host so later updates need no password" }
            rememberHost: { type: boolean, description: "Store host/port/user on the node (never the credentials)" }
            uninstall: { type: boolean, description: "Remove the node from the server instead of installing" }
    ProbeResult:
      type: object
      description: >
        What the host reports, what the node last reported about itself, and the
        plan the installer would follow. The cached fields matter because the
        node is usually offline when this is used; mismatch names the cached
        fact the host contradicts.
      properties:
        fingerprint: { type: string, description: "SHA256 fingerprint of the host's SSH key" }
        hostKeyNew: { type: boolean, description: "True when this connection pinned the key for the first time" }
        osId: { type: string }
        osName: { type: string }
        kernel: { type: string }
        machine: { type: string }
        arch: { type: string }
        root: { type: boolean }
        systemd: { type: boolean }
        docker: { type: boolean }
        dockerVersion: { type: string }
        compose: { type: boolean }
        curl: { type: boolean }
        tar: { type: boolean }
        pkgManager: { type: string, description: "The host's package manager, or empty if it has none the installer can drive" }
        installed: { type: string, enum: [none, script, docker] }
        installedVersion: { type: string }
        serviceState: { type: string }
        containerState: { type: string }
        image: { type: string }
        listen: { type: string }
        certPresent: { type: boolean }
        cachedSetup: { type: string }
        cachedArch: { type: string }
        cachedVersion: { type: string }
        cachedSeenAt: { type: integer, format: int64 }
        mismatch: { type: string, enum: ["", arch, method, missing] }
        method: { type: string, enum: ["", script, docker] }
        source: { type: string, enum: ["", upload, panel, github, docker] }
        targetVersion: { type: string }
        stagedVersion: { type: string }
        latestVersion: { type: string, description: "The newest node release the panel knows of; empty until the first check" }
        stagedForArch: { type: boolean }
        keyInstalled: { type: boolean }
        warnings: { type: array, items: { type: string } }
    InstallJob:
      type: object
      properties:
        id: { type: string }
        nodeId: { type: integer }
        status: { type: string, enum: [running, done, error] }
        message: { type: string }
        method: { type: string }
        version: { type: string }
        started: { type: integer, format: int64 }
        finished: { type: integer, format: int64 }
    Outbound:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        tag: { type: string }
        type: { type: string }
        config: { $ref: "#/components/schemas/JSON" }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    NodeClientUsage:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        nodeId: { type: integer }
        userId: { type: integer }
        dateTime: { type: integer, format: int64 }
        up: { type: integer, format: int64 }
        down: { type: integer, format: int64 }
    NodeUsage:
      type: object
      description: Per-user traffic on one node over a window, heaviest first.
      properties:
        start: { type: integer, format: int64, description: "Window start, unix seconds (inclusive)" }
        end: { type: integer, format: int64, description: "Window end, unix seconds (exclusive)" }
        users:
          type: array
          items:
            type: object
            properties:
              userId: { type: integer }
              name: { type: string, description: "Empty for a user deleted since the traffic was recorded" }
              up: { type: integer, format: int64 }
              down: { type: integer, format: int64 }
              last: { type: integer, format: int64, description: "Newest hour bucket with traffic, unix seconds" }
    NodeHealth:
      type: object
      description: One node's stored host readings over a window, with the uptime bar derived from them.
      properties:
        from: { type: integer, format: int64, description: "Window start, unix seconds (inclusive)" }
        to: { type: integer, format: int64, description: "Window end, unix seconds (exclusive)" }
        bucket: { type: integer, format: int64, description: "Resolution the uptime segments were derived at, in seconds: 300 inside the raw window, 3600 where the history has been compacted" }
        recording: { type: boolean, description: "False when node_health_retention_days is 0 — nothing is being kept, so an empty answer means switched off rather than no history" }
        samples:
          type: array
          description: "Oldest first. A figure of 0 means the node did not report it; a reading whose disk and memory totals were both zero was never stored at all."
          items:
            type: object
            properties:
              t: { type: integer, format: int64, description: "When the reading was taken, unix seconds" }
              diskTotal: { type: integer, format: int64 }
              diskUsed: { type: integer, format: int64 }
              memTotal: { type: integer, format: int64 }
              memUsed: { type: integer, format: int64 }
              load1: { type: number, format: double, description: "One-minute load average" }
        uptime:
          type: array
          description: "Runs of equal state tiling [from, to) exactly once, oldest first."
          items:
            type: object
            properties:
              from: { type: integer, format: int64 }
              to: { type: integer, format: int64 }
              state:
                type: string
                enum: [up, down, unknown]
                description: "up: the panel was running and the node reported. down: the panel was running and the node did not. unknown: the panel was off, the interval predates the node, or the node has never produced a reading at all — draw it as absence, never as an outage."
    TopUsers:
      type: object
      description: Heaviest users across the fleet over a window.
      properties:
        start: { type: integer, format: int64, description: "Window start, unix seconds (inclusive)" }
        end: { type: integer, format: int64, description: "Window end, unix seconds (exclusive)" }
        users:
          type: array
          items:
            type: object
            properties:
              userId: { type: integer }
              name: { type: string, description: "Empty for a user deleted since the traffic was recorded" }
              up: { type: integer, format: int64 }
              down: { type: integer, format: int64 }
              nodes: { type: integer, description: "Distinct nodes this user spent traffic on in the window" }
              last: { type: integer, format: int64, description: "Newest hour bucket with traffic, unix seconds" }
    NodeTotals:
      type: object
      description: Traffic per node over a window, heaviest first.
      properties:
        start: { type: integer, format: int64 }
        end: { type: integer, format: int64 }
        nodes: { type: array, items: { $ref: "#/components/schemas/NodeUsageRow" } }
    UserNodeUsage:
      type: object
      description: One user's traffic split across the nodes they used.
      properties:
        start: { type: integer, format: int64 }
        end: { type: integer, format: int64 }
        nodes: { type: array, items: { $ref: "#/components/schemas/NodeUsageRow" } }
    NodeUsageRow:
      type: object
      properties:
        nodeId: { type: integer }
        name: { type: string, description: "Empty for a node deleted since the traffic was recorded" }
        up: { type: integer, format: int64 }
        down: { type: integer, format: int64 }
        last: { type: integer, format: int64, description: "Newest hour bucket with traffic, unix seconds" }
    NodeLive:
      type: object
      description: The part of a node's status that is a figure of the moment, kept in the panel's memory between heartbeats.
      required: [connections, restarts, seenAt]
      properties:
        connections: { type: integer, description: "Connections the core holds open right now, TCP and UDP sessions alike" }
        restarts: { type: integer, description: "How many times the engine was started again since the node agent's process began" }
        lastError: { type: string, description: "The most recent engine start that failed, if any since the process began" }
        lastErrorAt: { type: integer, format: int64, description: "Unix seconds of lastError" }
        rejected:
          type: object
          additionalProperties: { type: integer, format: int64 }
          description: "Connections the node's router handed to each block outbound since its engine started, keyed by the outbound's tag — `block-torrent` for the torrent preset. Absent when the node has rejected nothing; a tag is only ever present with a count above zero."
        seenAt: { type: integer, format: int64, description: "Unix seconds the node reported these" }
        host:
          type: object
          description: "Disk and memory, sampled every five minutes on a tick of their own (the threshold alerts are judged on it); absent until the first sample. Zero totals mean the node did not say."
          properties:
            diskTotal: { type: integer, format: int64 }
            diskUsed: { type: integer, format: int64 }
            memTotal: { type: integer, format: int64 }
            memUsed: { type: integer, format: int64 }
            load1: { type: number }
            sampledAt: { type: integer, format: int64 }
    NodeConnections:
      type: object
      required: [total, users]
      properties:
        total: { type: integer, description: "Connections open on the node right now" }
        inbounds:
          type: object
          additionalProperties: { type: integer }
          description: Open connections per inbound tag
        users:
          type: array
          items:
            type: object
            required: [name, inbound, connections]
            properties:
              name: { type: string }
              inbound: { type: string, description: "Inbound tag the sessions arrived on" }
              connections: { type: integer }
    UserConnections:
      type: object
      required: [connections]
      properties:
        connections:
          type: array
          items:
            type: object
            required: [nodeId, node, inbound, connections]
            properties:
              nodeId: { type: integer }
              node: { type: string }
              inbound: { type: string, description: "Inbound tag the sessions arrived on" }
              connections: { type: integer }
    SessionInfo:
      type: object
      description: >
        One live connection as the node's registry knows it. Live state only —
        it is written nowhere; per-user destination logging is a separate,
        opt-in thing.
      required: [id, createdAt, up, down]
      properties:
        id: { type: string, description: "Row identity; unique on its node, gone after a node restart" }
        inbound: { type: string }
        user: { type: string }
        outbound: { type: string, description: "The outbound tag the connection was routed to" }
        network: { type: string, enum: [tcp, udp] }
        source: { type: string, description: "Client address, host:port" }
        destination: { type: string, description: "Where it went — the sniffed domain when there is one" }
        rule: { type: string, description: "The route rule that matched; empty when the final outbound took it" }
        createdAt: { type: integer, format: int64, description: "Unix seconds, when it was routed" }
        up: { type: integer, format: int64, description: "Bytes from the client so far" }
        down: { type: integer, format: int64, description: "Bytes to the client so far" }
    UserSessions:
      type: object
      required: [nodeId, node, total, sessions, supported]
      properties:
        nodeId: { type: integer }
        node: { type: string }
        total: { type: integer, description: "The user's open connections on this node, before any row filter" }
        inbounds:
          type: object
          additionalProperties: { type: integer }
          description: The user's connections per inbound tag on this node
        outbounds:
          type: object
          additionalProperties: { type: integer }
          description: The user's connections per outbound tag on this node
        sessions:
          type: array
          items: { $ref: "#/components/schemas/SessionInfo" }
        truncated: { type: boolean, description: "The rows stopped at the node's cap; the counts are still whole" }
        supported: { type: boolean, description: "False for a node too old to list rows; it still counts them" }
    CloseConnsFilter:
      type: object
      description: Which of the user's connections to drop. Every field optional; none means all of them.
      properties:
        nodeId: { type: integer, description: "One node; 0 or absent means every connected node" }
        inbound: { type: string }
        outbound: { type: string }
    CloseConnsResult:
      type: object
      required: [closed, nodes]
      properties:
        closed: { type: integer, description: "Connections dropped" }
        nodes: { type: integer, description: "Nodes that answered" }
        unsupported: { type: integer, description: "Nodes too old to know the route" }
        failed: { type: integer, description: "Nodes that could not be reached in time" }
    UserDevices:
      type: object
      required: [limit, devices]
      properties:
        limit: { type: integer, description: "The user's deviceLimit, 0 = unlimited" }
        devices:
          type: array
          items:
            type: object
            required: [id, userId, hwid, firstSeen, lastSeen]
            properties:
              id: { type: integer }
              userId: { type: integer }
              hwid: { type: string, description: "The client's hardware id, as sent in `x-hwid`" }
              platform: { type: string, description: "`x-device-os` as the client sent it; untrusted text" }
              osVersion: { type: string, description: "`x-ver-os`; untrusted text" }
              model: { type: string, description: "`x-device-model`; untrusted text" }
              userAgent: { type: string, description: "User-Agent of the last fetch from this device; untrusted text" }
              firstSeen: { type: integer, format: int64, description: "Unix seconds of the first fetch from this device" }
              lastSeen: { type: integer, format: int64, description: "Unix seconds of the most recent fetch, moved at most once a minute" }
    UserAddresses:
      type: object
      properties:
        limit: { type: integer, description: "The user's ipLimit, 0 = unlimited" }
        addresses:
          type: array
          items:
            type: object
            properties:
              address: { type: string, description: "CIDR prefix: a /32 for IPv4, the delegated /64 for IPv6" }
              seenAt: { type: integer, format: int64, description: "Unix seconds of the most recent connection from it" }
    StatsSeries:
      type: object
      description: >
        One downsampled traffic series. `up` and `down` are dense, equal-length
        and in bytes; point `i` covers
        `[startTime + i*bucketSpan, startTime + (i+1)*bucketSpan)`.
      properties:
        recording: { type: boolean, description: "False when stats_retention_days is 0 — nothing is being recorded, so every series is empty" }
        startTime: { type: integer, format: int64, description: "Left edge of the first bucket (unix seconds)" }
        endTime: { type: integer, format: int64, description: "Right edge of the last bucket (unix seconds)" }
        bucketSpan: { type: integer, format: int64, description: "Seconds covered by one point" }
        up: { type: array, items: { type: integer, format: int64 }, description: "Bytes from the client, per bucket" }
        down: { type: array, items: { type: integer, format: int64 }, description: "Bytes to the client, per bucket" }
        totalUp: { type: integer, format: int64 }
        totalDown: { type: integer, format: int64 }
    Ingress:
      type: object
      properties:
        shaped: { type: boolean, readOnly: true, description: "The old address settings can still describe and edit this table" }
        restart: { type: boolean, readOnly: true, description: "Set on a change's answer only by a process that cannot apply it live; it applies at the next start" }
        warnings:
          type: array
          readOnly: true
          description: "What saves and serves but probably not as meant: shadowed (an earlier route answers this one's host and path; other names it), never-answers, install-elsewhere (the installer only on a route the install command does not use), certificate (a host on a TLS listener none of its certificates carry; other is the route, detail the host), container-port (a listener inside a container, which may not publish its port)"
          items:
            type: object
            properties:
              kind: { type: string, enum: [listener, route] }
              name: { type: string }
              code: { type: string }
              other: { type: string }
              detail: { type: string }
        pending:
          type: object
          readOnly: true
          description: "The change waiting for its confirmation; on a change's answer, that change with the token that confirms it"
          properties:
            revision: { type: integer }
            confirmBy: { type: integer, format: int64, description: "Unix seconds; put back after it" }
            token: { type: string, description: "Only on the answer to the change itself" }
        listeners: { type: array, items: { $ref: "#/components/schemas/IngressListener" } }
        routes: { type: array, items: { $ref: "#/components/schemas/IngressRoute" } }
        fallbacks: { type: array, items: { $ref: "#/components/schemas/IngressFallback" } }
    IngressListener:
      type: object
      required: [name]
      properties:
        id: { type: integer, description: "Keeps the row across a save; absent matches by name, or makes a new row" }
        name: { type: string }
        primary: { type: boolean, description: "The listener the -listen / NX_WEB_LISTEN pin overrides; exactly one" }
        enabled: { type: boolean, description: "Absent is on; the primary listener is always on" }
        listenIp: { type: string, description: "An IP literal; empty listens everywhere (the primary listener: the address it was started with)" }
        listenPort: { type: string, description: "1-65535; the primary listener may leave it empty to keep the port it was started with" }
        tlsMode: { type: string, enum: ["", none, self_signed, files], description: "self_signed serves certId from the certificate store; files serves certFile/keyFile, which win when both are set" }
        certId: { type: integer }
        certFile: { type: string }
        keyFile: { type: string }
        certIds: { type: array, items: { type: integer }, description: "More store certificates, each answering the names it carries; the listener's own is the default" }
        unknownSni: { type: string, enum: ["", reject], description: "reject fails a handshake naming none of the listener's certificates" }
        strictSni: { type: boolean, description: "Refuse a request whose Host is not the name its TLS handshake asked for" }
        trustedProxies: { type: string, description: "Addresses and CIDRs whose X-Forwarded-For this listener believes" }
        fallback: { type: string, description: "The fallback's name; empty is a 404" }
        passthrough:
          type: array
          description: "Handshakes handed on untouched, tried in order, before the listener terminates anything"
          items:
            type: object
            required: [sni, target]
            properties:
              sni: { type: string, description: "A host, or *.name for one label under it" }
              target: { type: string, description: "host:port" }
        state: { $ref: "#/components/schemas/IngressListenerState" }
    IngressRevision:
      type: object
      properties:
        id: { type: integer }
        createdAt: { type: integer, format: int64 }
        actor: { type: string }
        source: { type: string }
        state: { type: string, enum: [applied, pending, reverted, superseded] }
        confirmBy: { type: integer, format: int64 }
        ingress: { $ref: "#/components/schemas/Ingress" }
    IngressListenerState:
      type: object
      readOnly: true
      properties:
        name: { type: string }
        address: { type: string }
        tls: { type: boolean }
        error: { type: string, description: "Why the listener is not bound, or serves plain HTTP" }
    IngressRoute:
      type: object
      required: [name, service, listeners]
      properties:
        id: { type: integer }
        name: { type: string }
        listeners: { type: array, items: { type: string }, description: "Listener names" }
        hosts: { type: array, items: { type: string }, description: "Empty matches every host; *.name matches one label" }
        excludeHosts: { type: array, items: { type: string } }
        prefix: { type: string, description: "Empty, or /name; stripped before the service sees the path" }
        service: { type: string, enum: [panel, sub, api, metrics, node_install, static, redirect, canned, upstream] }
        panel:
          type: object
          description: "A panel route's parts beyond the SPA and /api; absent is all four"
          properties:
            sub: { type: boolean }
            apiV1: { type: boolean }
            metrics: { type: boolean }
            nodeInstall: { type: boolean }
            tiers: { type: array, items: { type: string, enum: [sudo, admin, resale] }, description: "The tiers that may sign in on the route; empty is every tier. Never keeps the panel owner out" }
        api:
          type: object
          description: "What an api route admits; both empty (or absent) is every API token"
          properties:
            tokens: { type: array, items: { type: integer }, description: "API token ids" }
            addons: { type: array, items: { type: string }, description: "Addon slugs: the tokens that belong to them" }
        reply: { $ref: "#/components/schemas/IngressReply" }
        adminId: { type: integer, description: "The account the route is bound to; 0 is nobody. Panel, api and sub routes only" }
        brandingId: { type: integer, description: "The branding the route's pages carry; 0 is its bound account's, else none. Panel and sub routes only" }
        sub:
          type: object
          description: "A sub route's options"
          properties:
            ownedOnly: { type: boolean, description: "On a bound route: answer only the bound account's own customers; any other token gets the route's fallback. With `sub` absent, a new bound route gets it on and an existing route keeps what it had" }
        upstream:
          type: object
          description: "Where an upstream route proxies to: target or addon, one of the two"
          properties:
            target: { type: string, description: "An http(s) URL with no query" }
            addon: { type: string, description: "A registered addon's slug: its own address and certificate trust are used" }
            preserveHost: { type: boolean, description: "Send the request's own Host instead of the target's" }
            stripPrefix: { type: boolean, description: "Send the path under the route's prefix (X-Forwarded-Prefix names it); off sends the whole path" }
            tls: { type: string, enum: ["", pin, skip], description: "How an https target is verified: the system roots, pinSha256, or not at all" }
            pinSha256: { type: string, description: "The target leaf certificate's SHA-256, hex" }
            timeoutSeconds: { type: integer, description: "The wait for the target's response headers, 0-3600; 0 is a minute" }
        allowIps: { type: array, items: { type: string }, description: "The only client addresses or CIDRs the route answers, as the listener's trusted proxies resolve them; empty is every address" }
        headers: { type: object, additionalProperties: { type: string }, description: "Set on every answer the route gives; a header the service sets itself wins" }
        hstsMaxAge: { type: integer, description: "Strict-Transport-Security max-age in seconds, sent over TLS only; 0 is off" }
        hstsSubdomains: { type: boolean, description: "Adds includeSubDomains" }
        canonical: { type: boolean, description: "Links to the service are published on this route" }
        publicUrl: { type: string, description: "The origin links are built on, when something in front of the panel is what people reach" }
        fallback: { type: string }
        main: { type: boolean, readOnly: true, description: "The panel or subscription route on the primary listener whose domain and base path the old settings name: it cannot be removed, given another service, bound to an account or taken off the primary listener, only edited" }
    Branding:
      type: object
      required: [name]
      properties:
        id: { type: integer, readOnly: true }
        name: { type: string }
        panel:
          type: object
          properties:
            productName: { type: string }
            logo: { type: string, description: "A base64 data URI of an image" }
            favicon: { type: string, description: "A base64 data URI of an image" }
            palette: { type: string, enum: ["", violet, ocean, forest, sunset, rose] }
            density: { type: string, enum: ["", cosy, compact] }
            loginMessage: { type: string }
        sub:
          type: object
          properties:
            theme: { type: string, description: "A sub_theme value" }
            title: { type: string }
            supportUrl: { type: string }
            announce: { type: string }
            profileWebPage: { type: boolean, description: "Absent is the sub_profile_web_page setting's" }
        routes: { type: array, readOnly: true, items: { type: string }, description: "The routes carrying it" }
        accounts: { type: array, readOnly: true, items: { type: string }, description: "The accounts carrying it" }
        updatedAt: { type: integer, format: int64, readOnly: true }
    BrandingView:
      type: object
      properties:
        productName: { type: string }
        palette: { type: string }
        density: { type: string }
        loginMessage: { type: string }
        logo: { type: string, description: "A path under the panel's prefix" }
        favicon: { type: string, description: "A path under the panel's prefix" }
    IngressReply:
      type: object
      description: "An answer the ingress gives itself: a static, redirect or canned route's, or a fallback's. Only the fields of its kind count."
      properties:
        dir: { type: string, description: "static: an absolute directory, served with listings and dotfiles hidden and its own 404.html" }
        to: { type: string, description: "redirect: an http(s) URL, or `https` for the same URL over https" }
        code: { type: integer, enum: [0, 301, 302, 303, 307, 308], description: "redirect: 0 is 302" }
        keepPath: { type: boolean, description: "redirect: append the path under the route's prefix and the query to `to`" }
        status: { type: integer, description: "canned: 200-599; 0 is 200" }
        headers: { type: object, additionalProperties: { type: string }, description: "canned: response headers" }
        body: { type: string, description: "canned: at most 64 KiB" }
    IngressFallback:
      required: [name, kind]
      allOf:
        - type: object
          properties:
            id: { type: integer }
            name: { type: string }
            kind: { type: string, enum: [static, redirect, canned] }
        - $ref: "#/components/schemas/IngressReply"
    Setting:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        key: { type: string }
        value: { type: string }
    ListEnvelope:
      type: object
      description: The /api/v1 list shape. `items` holds the resource objects for the requested page.
      properties:
        items: { type: array, items: {} }
        total: { type: integer, format: int64, description: "Rows matching the filters, before limit/offset" }
        limit: { type: integer }
        offset: { type: integer }
    AddonRef:
      type: object
      description: The addon a token or a subscription belongs to; such a grant is changed on the Addons page.
      properties:
        id: { type: integer }
        name: { type: string }
        signed: { type: boolean }
    Addon:
      type: object
      required: [id, slug, name, baseUrl, signed, state, token, webhook]
      properties:
        id: { type: integer, readOnly: true }
        slug: { type: string, description: "The addon id: its token's addon" }
        name: { type: string }
        version: { type: string }
        publisher: { type: string }
        baseUrl: { type: string }
        entryUrl: { type: string, description: "The link to the addon's own interface; empty for none" }
        healthPath: { type: string, description: "Polled when set; empty for no check" }
        signed: { type: boolean }
        signedBy: { type: string, description: "Who signed the manifest: NX, development key, or developer key of <the directory listing's developer> — never a developer key's bare name" }
        signedByKind: { type: string, enum: [nx, developer, development, ""], description: "The kind of key that signed it, read from the key itself; empty on an addon registered before the panel recorded it, until its manifest is next read" }
        state: { type: string, enum: [registered, suspended, unhealthy] }
        healthAt: { type: integer, format: int64, description: "When the health path was last asked; 0 = never" }
        healthFailures: { type: integer, description: "Failed asks in a row; two make the addon unhealthy" }
        lastError: { type: string, description: "The last failed ask, cleared by the next 200" }
        manifestAt: { type: integer, format: int64, description: "When a signed addon's manifest was last read for an update" }
        pending:
          nullable: true
          type: object
          description: A signed addon's newer manifest that asks for more than it holds — only the additions
          required: [digest, version, scopes, events, rateLimit, newGrant]
          properties:
            digest: { type: string }
            version: { type: string }
            scopes: { type: array, items: { type: object, required: [scope, purpose], properties: { scope: { type: string }, purpose: { type: string } } } }
            events: { type: array, items: { type: object, required: [name, description, scope], properties: { name: { type: string }, description: { type: string }, scope: { type: string, description: "The scope that grants the event (the catalog's); the manifest must ask it, panel.addon_removed excepted." } } } }
            rateLimit: { type: integer, description: "A higher request rate it asks for the addon's token, in requests a minute; 0 when it asks no more. Approving it raises the token's rate." }
            newGrant: { type: boolean, description: "It asks for a token or a webhook the addon was never given; it cannot be approved in place" }
        tokenId: { type: integer }
        subscriberId: { type: integer }
        certificateId: { type: integer, nullable: true, description: "The panel certificate the addon fetches and serves its public address with; null when it gets its own or serves none" }
        local: { type: boolean, description: "It runs on the panel's own server, as its install judged it (installed here, or the SSH host or its address is this machine), kept only when it registered at the address its install was made for and until its address moves to another host; when false it is judged by its address, as one from before is" }
        pinnedCertSha256: { type: string, description: "The addon's own certificate the main admin trusted (hex SHA-256); empty for none" }
        registeredAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
        purposes: { type: object, additionalProperties: { type: string }, description: "A signed addon's reason for each scope" }
        token:
          nullable: true
          type: object
          properties:
            id: { type: integer }
            scopes: { type: array, items: { type: string } }
            lastUsedAt: { type: integer, format: int64 }
            lastUsedIp: { type: string }
        webhook:
          nullable: true
          type: object
          properties:
            id: { type: integer }
            url: { type: string }
            events: { type: array, items: { type: string } }
            enabled: { type: boolean }
            failures: { type: integer }
        credentials:
          type: object
          description: The token and the webhook signing secret, in the response that created them only
          properties:
            token: { type: string }
            secret: { type: string }
    AddonManual:
      type: object
      required: [name, baseUrl]
      properties:
        name: { type: string }
        slug: { type: string, description: "The addon id; derived from the name when empty. Fixed once registered." }
        baseUrl: { type: string }
        entryUrl: { type: string, description: "A path under baseUrl or an absolute URL" }
        healthPath: { type: string }
        token:
          nullable: true
          type: object
          properties:
            scopes: { type: array, items: { type: string } }
        webhook:
          nullable: true
          type: object
          properties:
            url: { type: string, description: "A path under baseUrl or an absolute URL" }
            events: { type: array, items: { type: string } }
    AddonConsent:
      type: object
      required: [baseUrl, digest, slug, name, version, publisher, signedBy, signedByKind, tier, scopes, rateLimit, events, webhookUrl, setupUrl, healthUrl, entryUrl]
      properties:
        baseUrl: { type: string }
        digest: { type: string, description: "Sent back by the registration, which refuses a manifest that changed since" }
        slug: { type: string }
        name: { type: string }
        version: { type: string }
        publisher: { type: string }
        signedBy: { type: string, description: "Who signed the manifest: NX, development key, or developer key of <the directory listing's developer>" }
        signedByKind: { type: string, enum: [nx, developer, development], description: "The kind of key that signed it, read from the key itself, never from its name" }
        tier: { type: string, enum: [official, verified, unofficial, ""], description: "The addon's tier in the addon directory; empty when the directory does not list it" }
        scopes: { type: array, items: { type: object, required: [scope, purpose], properties: { scope: { type: string }, purpose: { type: string } } } }
        rateLimit: { type: integer, description: "Requests a minute the token may make: the manifest's rateLimit (at most 600), else the panel's default, 120." }
        events: { type: array, items: { type: object, required: [name, description, scope], properties: { name: { type: string }, description: { type: string }, scope: { type: string, description: "The scope that grants the event (the catalog's); the manifest must ask it, panel.addon_removed excepted." } } } }
        webhookUrl: { type: string }
        setupUrl: { type: string }
        healthUrl: { type: string }
        entryUrl: { type: string }
        certSha256: { type: string, description: "The addon's certificate (hex SHA-256 of the leaf) when neither a CA nor the panel vouches for it — one it made itself; approving trusts it. Absent otherwise." }
        certNotAfter: { type: integer, format: int64, description: "When that certificate ends (Unix seconds)" }
    AddonDirectory:
      type: object
      required: [url, source, default, checkEnabled, site, generatedAt, fetchedAt, checkedAt, updates, addons]
      properties:
        url: { type: string, description: Where the index is read from }
        source: { type: string, description: "Where the index held was read from; differs from url after the address changed and the new one has not passed yet" }
        default: { type: boolean, description: "url is addons.nx-panel.org's, not a mirror" }
        checkEnabled: { type: boolean, description: "The update_check setting, which the daily read follows" }
        site: { type: string, description: "The directory's own address, from the signed index; empty before the first read" }
        generatedAt: { type: integer, format: int64, description: Unix seconds the index held was built }
        fetchedAt: { type: integer, format: int64, description: "Unix seconds of the last read that passed; 0 = never" }
        checkedAt: { type: integer, format: int64, description: Unix seconds of the last attempt }
        error: { type: string, description: Why the last attempt failed }
        updates: { type: integer, description: How many registered addons the directory lists a newer version of }
        addons: { type: array, items: { $ref: "#/components/schemas/AddonDirectoryEntry" } }
    AddonInstallOption:
      type: object
      required: [key, type, label, required, env]
      properties:
        key: { type: string }
        type: { type: string, enum: [string, secret, password, number, port, bool, choice, url, path], description: "password: a secret held to the addon sign-in's rule, 10 characters to 72 bytes" }
        label: { type: object, additionalProperties: { type: string } }
        help: { type: object, additionalProperties: { type: string } }
        default: { description: "A JSON value of the option's type" }
        required: { type: boolean }
        choices: { type: array, items: { type: string } }
        when: { type: object, additionalProperties: { type: string }, description: "Shown only while that earlier option has that value" }
        env: { type: string, description: "The environment variable the answer reaches the addon in" }
    AddonInstallPlan:
      type: object
      required: [entry, options, methods, panelUrl, registration]
      properties:
        entry: { $ref: "#/components/schemas/AddonDirectoryEntry" }
        options: { type: array, items: { $ref: "#/components/schemas/AddonInstallOption" } }
        methods: { type: array, items: { type: string, enum: [script, docker, compose] } }
        panelUrl: { type: string }
        registration: { type: string, enum: [claim, manual] }
        automatic: { type: boolean, description: "The panel may install it over SSH: official or verified, signed, with install.sh and a release" }
        automaticReason: { type: string, description: Why not, when it may not }
        local: { type: boolean, description: "The panel may also install it on its own server with no SSH: automatic, and the panel runs on Linux directly (not in a container) as root" }
        localReason: { type: string, description: "Why not on its own server, when it may not" }
    AddonSSH:
      type: object
      description: How the panel reaches an addon's host. Credentials are used for the connection and never stored.
      properties:
        sshHost: { type: string }
        sshPort: { type: integer }
        sshUser: { type: string, description: "Default root; another user escalates with sudo" }
        password: { type: string }
        privateKey: { type: string }
        passphrase: { type: string }
        installKey: { type: boolean, description: "Authorise the panel's key on the host so updating and removing it need no password" }
        source: { type: string, enum: [upload, host], description: "upload (default): the panel brings the release binary, checked against SHA256SUMS; host: the install script fetches it" }
        local: { type: boolean, description: "Install on the panel's own server: the panel runs the install itself, with no SSH; the other fields are unused. On an update, moves an addon installed over SSH on this very machine to a local install" }
    AddonJob:
      type: object
      properties:
        id: { type: string }
        addon: { type: string }
        action: { type: string, enum: [install, update, uninstall] }
        status: { type: string, enum: [running, done, error] }
        message: { type: string }
        started: { type: integer, format: int64 }
        finished: { type: integer, format: int64 }
    AddonHost:
      type: object
      required: [id, slug, kind, host, port, user, keyInstalled, method, version, installedAt, updatedAt, mayMoveLocal]
      properties:
        id: { type: integer }
        slug: { type: string }
        kind: { type: string, enum: [ssh, local], description: "ssh: host, port and user name the login; local: the panel's own server" }
        mayMoveLocal: { type: boolean, description: "An SSH login on the panel's own server, where local installs are possible: an update may move it to a local install" }
        host: { type: string }
        port: { type: integer }
        user: { type: string }
        keyInstalled: { type: boolean }
        method: { type: string }
        version: { type: string }
        installedAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
    AddonInstall:
      type: object
      required: [id, slug, name, version, method, baseUrl, signed, createdAt, expiresAt, checkedAt, lastError]
      properties:
        id: { type: integer }
        slug: { type: string }
        name: { type: string }
        version: { type: string }
        method: { type: string, enum: [script, docker, compose] }
        baseUrl: { type: string }
        signed: { type: boolean, description: "Registers by the issued claim code; false = added by hand" }
        createdAt: { type: integer, format: int64 }
        expiresAt: { type: integer, format: int64 }
        checkedAt: { type: integer, format: int64 }
        lastError: { type: string, description: Why the last check found it not ready }
        certificateId: { type: integer, nullable: true, description: The panel certificate chosen for it }
        local: { type: boolean, description: "It is to run on the panel's own server; carried to the addon by its registration" }
    AddonCertificate:
      type: object
      required: [certificate, key, notAfter, sha256]
      properties:
        certificate: { type: string, description: PEM chain, leaf first }
        key: { type: string, description: PEM private key }
        notAfter: { type: integer, format: int64 }
        sha256: { type: string, description: "Hex SHA-256 of the leaf's DER; also the ETag" }
    AddonInstallCreated:
      type: object
      required: [install, secret, registration]
      properties:
        install: { $ref: "#/components/schemas/AddonInstall" }
        command: { type: string, description: "Run on the addon's host as root; shown once" }
        secret: { type: boolean, description: The command carries a secret answer }
        registration: { type: string, enum: [claim, manual] }
        job: { $ref: "#/components/schemas/AddonJob" }
    AddonInstallCheck:
      type: object
      required: [ready]
      properties:
        ready: { type: boolean }
        reason: { type: string }
        consent: { $ref: "#/components/schemas/AddonConsent" }
    AddonDirectoryEntry:
      type: object
      required: [slug, tier, repo, developer, featured, name, version, publisher, license, paid, docs, requiresPanel, compatible, scopes, events, docker, platforms, options, installScript, stale, signedBy, page]
      properties:
        slug: { type: string }
        tier: { type: string, enum: [official, verified, unofficial] }
        repo: { type: string, description: The GitHub repository (owner/name) }
        developer: { type: string }
        featured: { type: boolean }
        name: { type: string }
        version: { type: string }
        publisher: { type: string }
        description: { type: object, additionalProperties: { type: string }, description: "By language: en, fa, ru, zh" }
        license: { type: string }
        paid: { type: boolean }
        docs: { type: string }
        requiresPanel: { type: string, description: "The manifest's requires.panel, e.g. >=0.1.0" }
        compatible: { type: boolean, description: This panel meets requiresPanel }
        scopes: { type: array, items: { type: object, required: [scope, purpose], properties: { scope: { type: string }, purpose: { type: string } } } }
        events: { type: array, items: { type: string } }
        docker: { type: boolean, description: The addon installs as a container }
        platforms: { type: array, items: { type: string }, description: "Platforms it ships a binary for (linux-amd64, …)" }
        options: { type: integer, description: How many questions its install asks }
        installScript: { type: boolean, description: Its repository ships install.sh }
        release:
          type: object
          nullable: true
          properties: { tag: { type: string }, publishedAt: { type: string }, url: { type: string } }
        stale: { type: boolean, description: "The directory's latest read of it failed; this is its last good manifest" }
        error: { type: string }
        signedBy: { type: string, enum: [nx, developer, development, unknown, none], description: "Who signed its manifest, as this panel sees it" }
        page: { type: string, description: The addon's page on the directory }
        registered:
          type: object
          nullable: true
          description: The addon registered here under this slug, if any
          properties:
            id: { type: integer }
            version: { type: string }
            update: { type: boolean, description: The directory lists a newer version than the one registered }
    ApiToken:
      type: object
      description: >
        A third-party API token. The plaintext `token` appears only in the
        create response; every other read omits it.
      properties:
        id: { type: integer, readOnly: true }
        token: { type: string, readOnly: true, description: "Plaintext, returned once by POST /api/tokens" }
        desc: { type: string }
        role: { $ref: "#/components/schemas/AdminRole" }
        adminId: { type: integer, description: "Operator the token acts as; 0 = a legacy unbound token acting on the whole panel" }
        owner: { type: string, readOnly: true, description: "Username of that operator" }
        scopes: { type: array, items: { type: string }, description: "Empty = unrestricted within the role" }
        addon: { type: string, description: "Addon id the token belongs to; empty for a token that is not an addon" }
        rateLimit: { type: integer, description: "Requests per minute, 0 = panel default" }
        expiry: { type: integer, format: int64, description: "Unix seconds, 0 = never" }
        lastUsedAt: { type: integer, format: int64, readOnly: true, description: "Unix seconds of the last authenticated request, 0 = never used" }
        lastUsedIp: { type: string, readOnly: true }
        createdAt: { type: integer, format: int64, readOnly: true }
        managedBy: { $ref: "#/components/schemas/AddonRef" }
    AddonData:
      type: object
      description: One addon's document on one user or admin.
      required: [addonId, data, version, updatedAt]
      properties:
        userId: { type: integer, description: "Present on a user row" }
        adminId: { type: integer, description: "Present on an admin row" }
        addonId: { type: string }
        data: { type: object, additionalProperties: true, description: "Exactly the object the addon last wrote; {} when never written" }
        version: { type: integer, description: "Number of writes so far; 0 when never written. Send it back on PUT for a conditional write." }
        updatedAt: { type: integer, format: int64, description: "Unix seconds of the last write, 0 when never written" }
    AddonDataRequest:
      type: object
      required: [data]
      properties:
        data: { type: object, additionalProperties: true, description: "The whole document; at most 16 KiB" }
        version: { type: integer, description: "The version last read. Absent = unconditional write." }
    BackupManifest:
      type: object
      description: The archive's own account of itself, written as its first entry.
      properties:
        format: { type: integer, description: "Archive layout version; a panel refuses anything newer than it understands" }
        panel_version: { type: string, description: "The build that wrote it" }
        driver: { type: string, enum: [sqlite, postgres] }
        hwid: { type: string, description: "The source host's fingerprint. The licence is locked to it, so an archive restored elsewhere comes up unlicensed." }
        web_domain: { type: string }
        basepath: { type: string }
        created_at: { type: integer, format: int64, description: "Unix seconds" }
        encrypted: { type: boolean }
        contents:
          type: object
          properties:
            history: { type: boolean }
            audit: { type: boolean }
        tables: { type: array, items: { $ref: "#/components/schemas/BackupTable" } }
        skipped:
          type: object
          additionalProperties: { type: string }
          description: "Table name → why it was left out (transient, excluded, or absent from the source schema)"
    BackupTable:
      type: object
      properties:
        name: { type: string }
        rows: { type: integer, format: int64 }
    BackupWarning:
      type: object
      description: >
        One advisory about restoring this archive here. Each is a decision for
        the operator, except the four a restore enforces:
        `version_mismatch`, `no_admins`, `unusable_ingress` and
        `unusable_kept_address`.
      properties:
        code:
          type: string
          enum: [version_mismatch, no_admins, unusable_ingress, unusable_kept_address, ingress_unchecked, hwid_mismatch, mtls_changed, node_pin_changed, ingress_settled, different_panel, no_history, no_audit]
          description: >
            `version_mismatch` — written by another panel version than this
            one (the archive's in `detail`), older or newer: a restore refuses
            it with 400 `version_mismatch`.
            `no_admins` — the archive holds no main-admin (sudo) account. This
            is one of the advisories a restore also enforces (400 `no_admins`), and
            the preview applies the same test so the two cannot disagree.
            `unusable_ingress` — restored with its own addresses (this
            install's not kept, as the setup wizard restores), the archive is
            refused with 400 `unusable_ingress`: why is in `detail`.
            `unusable_kept_address` — the same refusal when this install's
            address is kept (`keep_web_settings`, the default). Both come from
            running the restore's own address checks over the archive's rows,
            so a preview and a restore cannot disagree.
            `ingress_unchecked` — the archive holds more rows (over 50,000) or
            bytes (over 64 MiB) of the tables the address check reads than a
            preview keeps in memory, so the preview did not check its
            addresses; the restore still checks them and may refuse with 400
            `unusable_ingress`.
            `ingress_settled` — what the restore changes in the addresses it
            stages so they serve, in `detail`: a listener whose certificate the
            archive lacks switched off, a route admitting only deleted tokens
            or bound to an account the archive lacks answering 404 (prefixed
            "keeping this install's address:" for the kept address).
            `hwid_mismatch` — a different host fingerprint: the licence
            travelling inside will not validate here.
            `mtls_changed` — the archive holds a different panel mTLS client
            certificate, which is the CA every node was installed to trust:
            restoring it makes every node refuse this panel until each is
            reinstalled.
            `node_pin_changed` — the archive's pinned server certificate for
            one or more nodes (named in `detail`) differs from the live one, so
            those nodes' control APIs would be refused until they are repinned.
            `different_panel` — a different web domain, the visible sign of an
            archive from another install.
            `no_history` / `no_audit` — the optional groups were excluded, so a
            restore empties the usage graphs / the audit log.
        detail: { type: string, description: "The specifics the message needs (a version, a fingerprint, a hostname)" }
    BackupPreview:
      type: object
      properties:
        manifest: { $ref: "#/components/schemas/BackupManifest" }
        encrypted: { type: boolean, description: "Whether the archive was passphrase-protected. Only ever seen after a successful decrypt — an archive that could not be opened answers 400 with code `encrypted` or `passphrase` instead, which is the signal a UI keys a passphrase prompt off." }
        tables: { type: array, items: { $ref: "#/components/schemas/BackupTable" }, description: "What was actually found, verified against the manifest" }
        warnings: { type: array, items: { $ref: "#/components/schemas/BackupWarning" }, description: "Most consequential first" }
        size: { type: integer, format: int64, description: "The uploaded archive's size in bytes" }
    BackupRestore:
      type: object
      description: >
        What a restore loaded. `staged` says whether it is still waiting for
        the restart (SQLite: the live database is untouched until the file
        swap) or already committed (PostgreSQL: the restart only reloads what
        the process holds in memory).
      properties:
        manifest: { $ref: "#/components/schemas/BackupManifest" }
        tables: { type: integer, description: "Tables loaded" }
        rows: { type: integer, format: int64, description: "Rows loaded" }
        snapshot: { type: string, description: "Path on the panel host of the full backup taken of the database being replaced" }
        staged: { type: boolean, description: "True on SQLite: the replacement is a separate file and the live database is still untouched until the restart. False on PostgreSQL, where the transaction has already committed and the restart only reloads in-memory state." }
        kept:
          type: array
          items: { type: string }
          description: "Settings this installation held on to instead of taking the archive's"
        skipped_tables:
          type: array
          items: { type: string }
          description: "Tables in the archive this schema no longer has"
        dropped_columns:
          type: object
          additionalProperties: { type: array, items: { type: string } }
          description: "Table → columns the archive carried that this schema does not. This is how an archive from another version loads at all."
        restarting: { type: boolean }
    BackupFile:
      type: object
      description: One archive in the panel host's backup directory.
      properties:
        name: { type: string, description: "File name; the identifier every other route on this collection takes" }
        size: { type: integer, format: int64 }
        created_at: { type: integer, format: int64, description: "Unix seconds, read from the timestamp in the file name — not the modification time, which changes when the directory is copied, and not the manifest, which an encrypted archive does not show. Every route reports the same value for the same file." }
        encrypted: { type: boolean }
        kind:
          type: string
          enum: [backup, pre-restore]
          description: >
            `backup` is a scheduled or manually taken backup. `pre-restore` is
            the safety copy taken of a database that was about to be replaced —
            retention never deletes one, and it does not count as evidence that
            the panel has been backed up.
        manifest: { $ref: "#/components/schemas/BackupManifest" }
    BackupList:
      type: object
      properties:
        dir: { type: string, description: "Where the archives live on the panel host" }
        files: { type: array, items: { $ref: "#/components/schemas/BackupFile" }, description: "Newest first" }
        total: { type: integer }
        bytes: { type: integer, format: int64, description: "Total size of the directory's archives" }
        schedule: { $ref: "#/components/schemas/BackupSchedule" }
    BackupSchedule:
      type: object
      properties:
        enabled: { type: boolean }
        intervalHours: { type: integer, description: "How often to take one; the hourly scheduler decides due-ness from the newest archive on disk" }
        dir: { type: string }
        keep: { type: integer, description: "How many backups to keep; 0 keeps everything. Pre-restore snapshots are never pruned." }
        includeHistory: { type: boolean }
        includeAudit: { type: boolean }
        passphraseSet: { type: boolean, readOnly: true, description: "Whether scheduled archives are encrypted. The passphrase itself is never returned." }
        nextDue: { type: integer, format: int64, readOnly: true, description: "Unix seconds, 0 when the schedule is off or there is no archive the timer counts — pre-restore snapshots and archives stamped more than an hour ahead of now are not counted, exactly as the scheduler does not count them" }
    BackupScheduleUpdate:
      type: object
      description: >
        A partial update: every field is optional, and one that is absent (or
        null) leaves the stored value alone. That is the only safe reading of
        an all-optional object — a client sending just the field it means to
        change must not have the schedule switched off and the directory moved
        underneath it — and it is how the write-only passphrase has always
        behaved. Sending the whole object, as the panel's own form does,
        replaces the whole schedule.
      properties:
        enabled: { type: boolean, nullable: true }
        intervalHours: { type: integer, minimum: 0, maximum: 8760, nullable: true, description: "0 takes the default (24h): enabled with no interval is a half-filled form, not a request for nothing. Capped at a year. Null or absent leaves the stored value alone — a cleared number box must not silently rewrite the schedule." }
        dir: { type: string, nullable: true, description: "Absolute path, or empty for the default beside the database. A relative path is refused: it would resolve against the panel process's working directory. Null or absent leaves the stored directory alone." }
        keep: { type: integer, minimum: 0, nullable: true, description: "0 keeps everything, and is stored as 0 — not as the default. Null or absent leaves the stored value alone, which is how a cleared box differs from a deliberate 0." }
        includeHistory: { type: boolean, nullable: true }
        includeAudit: { type: boolean, nullable: true }
        passphrase: { type: string, writeOnly: true, description: "Empty leaves the stored one alone" }
        clearPassphrase: { type: boolean, writeOnly: true, description: "Removes the stored passphrase, so scheduled archives stop being encrypted" }
    BackupError:
      type: object
      properties:
        error: { type: string }
        code: { type: string, enum: [encrypted, passphrase, format, too_large, confirm, busy, version_mismatch, no_admins, unusable_ingress, unsupported] }
    Change:
      type: object
      description: >
        One audit record: a mutating admin or API-token action, the operator that
        performed it, and the payload it carried.
      properties:
        id: { type: integer, format: int64, readOnly: true }
        dateTime: { type: integer, format: int64, readOnly: true, description: "Unix seconds" }
        actor: { type: string, readOnly: true, description: "Operator username, or \"system\" for an unattended action" }
        key: { type: string, readOnly: true, description: "Entity type: user, inbound, outbound, endpoint, node, template, certificate, panel_certificate, admin, token, license, panel, changes" }
        action: { type: string, readOnly: true, description: "create | update | delete, plus per-entity verbs (issue, renew, purge, restart, …)" }
        obj: { description: "The action's payload, with secret-looking fields replaced by \"[redacted]\"" }
    IPBan:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        prefix: { type: string, description: "An IP address or a CIDR" }
        reason: { type: string }
        source: { type: string, enum: [auto, manual], readOnly: true }
        createdBy: { type: string, readOnly: true, description: "The admin, or `system` for an automatic ban" }
        createdAt: { type: integer, format: int64, readOnly: true }
        expiresAt: { type: integer, format: int64, readOnly: true, description: "Unix seconds; 0 = permanent" }
    InstallToken:
      allOf:
        - $ref: "#/components/schemas/Token"
        - type: object
          properties:
            pin: { type: string, description: "curl's --pinnedpubkey form, sha256//<base64 SPKI digest>; absent when curl can verify the panel without it" }
    Token:
      type: object
      description: Node-install token (see ApiToken for third-party API tokens).
      properties:
        id: { type: integer, readOnly: true }
        kind: { type: string, enum: [node_install, api] }
        token: { type: string }
        desc: { type: string }
        role: { $ref: "#/components/schemas/AdminRole" }
        scopes: { type: string }
        usedAt: { type: integer, format: int64, nullable: true }
        expiry: { type: integer, format: int64 }
        createdAt: { type: integer, format: int64, readOnly: true }
    Endpoint:
      type: object
      properties:
        id: { type: integer, readOnly: true }
        tag: { type: string }
        type: { type: string, description: "wireguard, tailscale, openvpn-server, openconnect-server, ..." }
        config: { $ref: "#/components/schemas/JSON" }
        useNodeCert: { type: boolean, description: "Replace a server endpoint's (openvpn/openconnect-server) TLS cert with the node's managed cert" }
        certificateId: { type: integer, nullable: true, description: "Panel certificate assigned to this endpoint's TLS (wins over useNodeCert)" }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    PresetItem:
      type: object
      properties:
        tag: { type: string, description: "The tag the rule set would be created under, which is also half its download URL." }
        url: { type: string, description: "Where the panel fetches the list from." }
        label: { type: string, description: "Shown as written. An operator's own entry carries this." }
        labelKey: { type: string, description: "An i18n key the interface resolves. A built-in entry carries this." }
    PresetRoute:
      type: object
      description: >
        A routing block an operator can apply to a template. `requires` is the
        load-bearing field: the panel leaves out a rule set it holds no copy of
        **and every rule matching on it** — it must, since the core refuses a
        router whose rule names an undefined rule-set — so a preset applied
        without its lists is a template that does nothing and reports nothing.
        Naming the tags lets a client check them first and offer to create the
        missing ones off the rule-set shelf in this same document, which is why
        both shelves live in one catalogue.
      properties:
        key: { type: string }
        label: { type: string }
        labelKey: { type: string }
        description: { type: string }
        descriptionKey: { type: string }
        requires:
          type: array
          items: { type: string }
          description: "Rule-set tags the rules match on. Every one of them is also an entry on the rule-set shelf, so a client can create what is missing."
        route: { type: object, additionalProperties: true, description: "A route object (rules + final). `direct` is the only outbound every node has." }
        compose:
          type: boolean
          description: "True for a preset whose rules are put **in front of** the template's rules rather than replacing them. The country presets replace, because rule order is the routing; a self-contained preset (sniff, then reject a sniffed protocol) has to run first, and first is an order."
        outbounds:
          type: array
          description: "Pool outbounds the route names. A client creates any the panel lacks (by tag) and selects them on the template — the block-torrent preset routes to a `block` outbound because a rejection through an outbound is the only kind the node counts."
          items:
            type: object
            properties:
              tag: { type: string }
              type: { type: string }
              config: { type: object, additionalProperties: true }
    PresetDns:
      type: object
      description: "A set of resolvers and which one answers by default, as a typed `dns` block ready to drop into a template's core. The older `address` form is not used: a node running the current core refuses a config carrying it."
      properties:
        key: { type: string }
        label: { type: string }
        labelKey: { type: string }
        description: { type: string }
        descriptionKey: { type: string }
        dns: { type: object, additionalProperties: true }
    PresetInbound:
      type: object
      description: >
        One "make me this kind of inbound". It carries the decisions and never
        the per-install randomness: no port, no key, no path. The port and the
        generated secrets come from the interface's own defaults for the type,
        the transport path is generated (one frozen into the document would be
        identical on every install, which is a fingerprint), and the REALITY
        private key and short IDs are minted by the panel when the inbound is
        saved — which is why an operator never types a key. What it produces is
        an ordinary inbound row; nothing records which preset made it.
      properties:
        key: { type: string, description: "Its identity, and what an overlay file matches on to replace it." }
        label: { type: string }
        labelKey: { type: string }
        description: { type: string }
        descriptionKey: { type: string }
        type: { type: string, description: "The inbound type." }
        tag: { type: string, description: "The base the created inbound's tag is built from; a short random suffix is appended. Absent = the type." }
        transport: { type: string, description: "A stream transport (ws, grpc, httpupgrade, …), applied the way the form applies one an operator picked." }
        useNodeCert: { type: boolean, description: "Point the inbound at the node's managed certificate. Needed wherever the preset turns TLS on for a protocol that does not require it." }
        config: { type: object, additionalProperties: true, description: "Merged over the interface's default config for `type`." }
        clientConfig: { type: object, additionalProperties: true, description: "Client-side hints merged into generated links." }
    PresetGroup:
      type: object
      properties:
        key: { type: string, description: "The group's identity, and what an overlay file matches on to replace it." }
        label: { type: string }
        labelKey: { type: string }
        items: { type: array, items: { $ref: "#/components/schemas/PresetItem" } }
    PresetCatalogue:
      type: object
      properties:
        version: { type: integer, description: "The document's shape. 1 today; later sessions add sections rather than changing these." }
        ruleSets:
          type: object
          properties:
            countries:
              type: array
              description: "One group per country. Domains and IP ranges are two entries, not one — a rule matching only domains misses a client that dialled an address."
              items: { $ref: "#/components/schemas/PresetGroup" }
            services: { type: array, items: { $ref: "#/components/schemas/PresetItem" } }
        inbounds:
          type: array
          description: "The shelf behind “add an inbound from a preset”: a protocol with its transport and TLS decision already made."
          items: { $ref: "#/components/schemas/PresetInbound" }
        routes:
          type: array
          description: "Routing blocks for a template, composed of rule sets the panel already mirrors."
          items: { $ref: "#/components/schemas/PresetRoute" }
        dns:
          type: array
          description: "Resolver sets for a template's core, in the typed server form."
          items: { $ref: "#/components/schemas/PresetDns" }
        sources:
          type: array
          items: { type: string }
          description: "The operator catalogue files that were read, in the order they were applied. Always present; empty means the panel is serving only what it ships."

    RuleSet:
      type: object
      description: >
        A remote rule set the panel mirrors. The panel downloads the
        list from `url`, keeps a copy, refreshes it every `updateIntervalHours`,
        and pushes that copy to each node over its control link — nodes never
        fetch anything. A node whose first rule-set fetch fails refuses its whole
        configuration, so nobody's availability must be able to decide whether
        the fleet runs. A node too old to take the push (before node v0.0.2) is
        given no rule sets, and no rule naming one.


        Templates select rule sets the way they select pool items, and the
        selection becomes each node's `route.rule_set[]`. Only the remote kind
        exists: a local rule-set names a file the panel does not put on the node,
        and an inline one is a rule list, which `route.rules` already is.
      properties:
        id: { type: integer, readOnly: true }
        tag:
          type: string
          description: >
            The name a rule matches on. Letters, digits, '-', '_' and '.' only,
            because it also names the file on each node. Fixed after creation.
        url: { type: string, description: "Absolute http(s) upstream the panel downloads from. Never sent to a node." }
        updateIntervalHours: { type: integer, description: "How often the panel re-downloads from upstream. A refresh that changed the bytes pushes them to the nodes carrying the list." }
        format: { type: string, enum: [binary, source], readOnly: true, description: "Detected from the downloaded bytes, never chosen" }
        available: { type: boolean, readOnly: true, description: "The panel holds a copy. An unavailable rule set, and every rule naming it, is left out of node configurations." }
        size: { type: integer, format: int64, readOnly: true }
        ruleCount: { type: integer, readOnly: true }
        contentHash: { type: string, readOnly: true, description: "sha256 of the mirrored bytes; a node already holding them is not pushed them again" }
        lastFetchedAt: { type: integer, format: int64, readOnly: true, description: "Unix seconds; 0 = never downloaded" }
        lastAttemptAt: { type: integer, format: int64, readOnly: true }
        lastError: { type: string, readOnly: true, description: "Why the last refresh did not land. The previous copy is kept." }
        createdAt: { type: integer, format: int64, readOnly: true }
        updatedAt: { type: integer, format: int64, readOnly: true }
    RouteTestForm:
      type: object
      required: [inbounds]
      properties:
        inbounds:
          type: array
          items:
            type: object
            required: [tag]
            properties:
              tag: { type: string }
              type: { type: string, description: "The panel's type for the tag; absent when the panel has no record of it" }
              remark: { type: string, description: "The inbound's remark, or the tunnel's name" }
    RouteTestRequest:
      type: object
      required: [inbound, address, port]
      properties:
        inbound: { type: string, description: The inbound's tag on the node }
        user: { type: string, description: "The user's name; empty for a connection that carries none" }
        network: { type: string, enum: [tcp, udp], description: "Default tcp" }
        source: { type: string, description: "The client's address, an IP with or without a port; optional" }
        address: { type: string, description: The destination as the client asked, an IP or a domain }
        port: { type: integer, minimum: 1, maximum: 65535 }
        domain: { type: string, description: "The sniffed domain (TLS SNI, HTTP Host); optional" }
        protocol: { type: string, description: "The sniffed protocol (tls, http, quic, bittorrent, …); optional" }
    RouteTestStep:
      type: object
      required: [index, rule, action, matched]
      properties:
        index: { type: integer }
        rule: { type: string, description: "The rule in the engine's own words — what a live connection's row shows for it" }
        action: { type: string }
        matched: { type: boolean }
        final: { type: boolean, description: This rule ended the walk }
        skipped: { type: string, enum: [sniff, resolve], description: "A matched action the dry run could not perform without real traffic" }
    RouteDialResult:
      type: object
      required: [ok, connect_ms]
      properties:
        ok: { type: boolean }
        connect_ms: { type: integer, format: int64, description: "Time to connect in milliseconds; 0 when it never connected" }
        answered: { type: boolean, description: The destination sent the first bytes }
        unconfirmed:
          type: boolean
          description: >
            OK through a tunnel or proxy with nothing back to say the destination
            was reached: no tunnel or proxy reports how its far end's own connect
            went, and a far end still trying looks the same. Never set for a
            direct connect, which is the destination accepting.
        error: { type: string }
    RouteTestResult:
      type: object
      required: [steps, action, rule_index, destination]
      properties:
        steps: { type: array, items: { $ref: "#/components/schemas/RouteTestStep" } }
        action: { type: string, description: "How the walk ended: route, reject, hijack-dns, bypass, or final (no rule ended it)" }
        rule_index: { type: integer, description: "-1 for the final outbound" }
        rule: { type: string }
        outbound: { type: string }
        outbound_type: { type: string }
        destination: { type: string, description: "Where the connection goes after any override_address / override_port" }
        error: { type: string, description: What the router itself would refuse the connection with }
        dial: { $ref: "#/components/schemas/RouteDialResult" }
    RuleSetTestRequest:
      type: object
      required: [address]
      properties:
        tags: { type: array, items: { type: string }, description: "Empty: every rule set the node's route declares" }
        address: { type: string }
        port: { type: integer, minimum: 0, maximum: 65535 }
    RuleSetTestResult:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items:
            type: object
            required: [tag, loaded, matched]
            properties:
              tag: { type: string }
              loaded: { type: boolean }
              matched: { type: boolean }
    CheckOutboundRow:
      type: object
      required: [tag, kind]
      properties:
        tag: { type: string }
        type: { type: string, description: "The panel's type for the tag; absent when the panel has no record of it" }
        kind: { type: string, enum: [outbound, endpoint] }
    OutboundCheckResult:
      type: object
      properties:
        ok: { type: boolean }
        delay: { type: integer, description: "Latency in milliseconds" }
        error: { type: string }
    ServerInfo:
      type: object
      properties:
        core_version: { type: string }
        node_version: { type: string }
        uptime: { type: integer, format: int64 }
        num_goroutine: { type: integer }
        arch: { type: string, description: "GOARCH of the node build (amd64, arm64, …); empty from nodes older than this field" }
        setup: { type: string, description: "How the node is deployed: the container runtime (docker, podman, containerd, kubepods, lxc) or the host OS (linux) for a plain install" }
        num_cpu: { type: integer }
        mem_alloc: { type: integer, format: int64 }
        mem_sys: { type: integer, format: int64 }
        sys_mem_total: { type: integer, format: int64 }
        sys_mem_used: { type: integer, format: int64 }
        load_avg_1: { type: number }
        load_avg_5: { type: number, description: "5-minute load average; 0 from nodes older than the field or platforms that do not report one" }
        load_avg_15: { type: number, description: "15-minute load average; 0 as above" }
        sys_swap_total: { type: integer, format: int64, description: "Host swap in bytes; 0 for a host with no swap as well as when unknown" }
        sys_swap_used: { type: integer, format: int64 }
        disk_total: { type: integer, format: int64, description: "Size of the node's root filesystem in bytes; 0 when unknown" }
        disk_used: { type: integer, format: int64, description: "Used bytes of the root filesystem, counting root-reserved blocks (as df does)" }
        host_uptime: { type: integer, format: int64, description: "Seconds since the host booted — the machine's uptime, where `uptime` is the engine's" }
    PanelAddress:
      type: object
      description: >
        Where a panel process answers, in the parts a browser needs to rebuild
        its own URL. `port` is the one bound, which a reverse proxy in front
        may hide; `domain` is empty when no panel domain restricts the host.
      required: [scheme, port, domain, basepath]
      properties:
        scheme: { type: string, enum: [http, https] }
        port: { type: string }
        domain: { type: string }
        basepath: { type: string, description: "`/prefix`, or empty at the root" }
    PanelInfo:
      type: object
      description: The panel's own health readout, for the dashboard's Panel tiles.
      properties:
        version: { type: string }
        go_version: { type: string }
        uptime: { type: integer, format: int64, description: Seconds since the panel process started }
        arch: { type: string, description: "GOARCH of the panel build (amd64, arm64, …)" }
        setup: { type: string, description: "How the panel is deployed: the container runtime (docker, podman, …) or the host OS (linux) for a plain install" }
        num_cpu: { type: integer }
        num_goroutine: { type: integer }
        mem_alloc: { type: integer, format: int64 }
        mem_sys: { type: integer, format: int64 }
        sys_mem_total: { type: integer, format: int64 }
        sys_mem_used: { type: integer, format: int64 }
        load_avg_1: { type: number }
        disk_total: { type: integer, format: int64, description: Size of the root filesystem in bytes; 0 where the platform does not report one }
        disk_used: { type: integer, format: int64, description: Used bytes of the root filesystem, counting root-reserved blocks (as df does) }
        db_driver: { type: string, description: "sqlite or postgres" }
        db_size: { type: integer, format: int64, description: On-disk database size in bytes; 0 when the backend does not report one }
        db_usage_rows: { type: integer, format: int64, description: Rows in node_client_usages, the table that grows with traffic accounting }
        backup_last_at: { type: integer, format: int64, description: "Unix seconds of the newest archive the panel's own machinery counts, 0 = never. A directory that quietly stopped filling up is the failure scheduled backups have, and nothing else on the panel would show it. Sudo only — see below." }
        backup_count: { type: integer, description: "How many such archives there are. Pre-restore snapshots are excluded (they are copies of a replaced database, not evidence of a backup), as is anything stamped more than an hour ahead of now, which retention and the timer also step over — so this is the set retention actually bounds. Sudo only." }
        backup_enabled: { type: boolean, description: Whether scheduled backups are on (sudo only) }
        backup_interval_hours: { type: integer, description: "How often the schedule runs, so a client can say whether the last backup is late rather than assuming a daily one (sudo only). All four fields are 0/false for anyone else — including a token without `backup:read`, since every backup route demands that scope — so a client that cannot see them must not read them as \"never backed up\"." }
        update:
          allOf: [{ $ref: "#/components/schemas/PanelUpdateStatus" }]
          description: "The release check and the install method (sudo only; absent for everyone else). The banner is drawn from this, so it rides the poll the dashboard already makes."
    PanelUpdateStatus:
      type: object
      description: >
        What the daily release check last saw, and how this panel was installed.
        The two halves are separate questions: whether a newer release exists,
        and whether this install can replace itself with it.
      properties:
        current: { type: string, description: The version this binary was built as }
        latest: { type: string, description: "The newest release tag the check has seen; empty until the first successful check" }
        notes: { type: string, description: Link to that release on the public panel repository }
        checkedAt: { type: integer, format: int64, description: "Unix seconds of the last check, successful or not — a failure is stamped too, because the stamp is what paces the retry" }
        available: { type: boolean, description: latest is newer than current }
        checkEnabled: { type: boolean, description: "The update_check setting; false means the panel asks nothing on its own and the figures above are whatever the last check left" }
        error: { type: string, description: "Why the last check failed, or absent. An install with nothing outbound shows a reason here rather than logging one every day" }
        method: { type: string, enum: [systemd, container, manual], description: "How this panel was installed. Only a systemd install can replace itself" }
        runtime: { type: string, description: "The container runtime, when method is container" }
        unit: { type: string, description: "The systemd unit this process runs under, read from its own cgroup" }
        binary: { type: string, description: The running executable, which is the file an update replaces }
        reason: { type: string, description: "Why this panel may not update itself, in prose for the operator; absent exactly when it may" }
        canUpdate: { type: boolean }
        addonUpdates: { type: integer, description: "How many registered addons the addon directory lists a newer version of" }
        nodeLatest: { type: string, description: "The newest node release tag the check has seen; empty until the first successful check. Learned by the same check as the panel's, on its own record" }
        nodeNotes: { type: string, description: "Link to that release on the public node repository" }
        nodeError: { type: string, description: "Why the last node lookup failed, or absent; separate from `error` so one stream failing blanks nothing the other learned" }
        nodesBehind: { type: integer, description: "How many nodes report a version older than nodeLatest; a node that has not reported one is not counted" }
        nodeCache:
          type: array
          description: The node binaries this panel holds for installers, one per architecture
          items:
            type: object
            properties:
              arch: { type: string }
              version: { type: string, description: "Empty when the panel cannot tell" }
              current: { type: boolean, description: "It is nodeLatest" }
        nodeCacheCurrent: { type: boolean, description: "Every architecture the fleet needs is cached at nodeLatest. When false, an install downloads the newest node from GitHub instead of uploading the older cached one" }
    PanelUpdateJob:
      type: object
      description: >
        One upgrade. The record lives in a file beside the binary rather than in
        the database, because the panel exits in the middle of it: the rollback
        watchdog and the new process both read it, and the watchdog must not
        open a database the new build may have migrated.
      properties:
        id: { type: string }
        from: { type: string }
        to: { type: string }
        phase:
          type: string
          enum: [backup, download, verify, swap, restarting, done, error, rolled_back]
          description: "Anything but done, error and rolled_back means the job is still moving"
        message: { type: string }
        error: { type: string }
        backup: { type: string, description: "The archive taken before anything was touched — the way back from a migration, since migrations are forward-only" }
        previous: { type: string, description: Where the replaced binary is kept }
        actor: { type: string }
        startedAt: { type: integer, format: int64 }
        finishedAt: { type: integer, format: int64 }
    PanelUpdate:
      type: object
      properties:
        status: { $ref: "#/components/schemas/PanelUpdateStatus" }
        job: { $ref: "#/components/schemas/PanelUpdateJob" }
        log:
          type: array
          items: { type: string }
          description: "The job log, tail-capped. It is a file, so a browser that reconnects after the panel restarted still reads the whole run"
