openapi: 3.0.3
info:
  title: Nexora Panel API
  description: >
    Control-plane REST API for the Nexora 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: node-mappings
  - 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 (`nexora-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 (`nexora-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; `nexora_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; `nexora_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/Nexora:<username>?secret=…&issuer=Nexora — 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.
      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)
      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 }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Admin" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $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).
      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 }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Admin" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $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).
      responses:
        "200": { description: Deleted }
        "400": { $ref: "#/components/responses/Error" }
        "403": { $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: integer, minimum: 0 }, description: "Owner: a reseller's admin id, or 0 for users owned by the panel. 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 reaching the inbound through any template that carries it" }
        - { name: protocol, in: query, schema: { type: string }, description: "Users reaching any inbound of this type (`vless`, `hysteria2`, …)" }
        - { 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" }
    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.


        An operator or a token naming `adminId` hands the new account to that
        reseller, and the account is held to the reseller's own rules exactly as
        if it had made it: `adminId` must name a reseller (`400` for an operator
        or nobody; `0` is the panel's own), 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 reseller.


        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.
      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/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] } }
        - { $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: integer, minimum: 0 } }
        - { 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: 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` and `limits` rewrite what an account was
        sold, so a `plansOnly` reseller is refused them (`403`) while the rest
        stay open to it.


        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)." }
      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" }
        "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: integer, minimum: 0 } }
        - { 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: 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.
      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
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Outbound" } } } }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Outbound" } } } } }
  /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
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } } }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } } } }

  /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
      requestBody: { $ref: "#/components/requestBodies/Node" }
      responses: { "200": { $ref: "#/components/responses/Node" } }
  /api/nodes/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Update a node
      requestBody: { $ref: "#/components/requestBodies/Node" }
      responses:
        "200": { $ref: "#/components/responses/Node" }
        "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`, `/routing` and the per-node outbound and endpoint creates; 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}/routing:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [nodes]
      summary: Set the node's inline routing (overrides template route)
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: { routing: { $ref: "#/components/schemas/JSON" } } } } }
      responses: { "200": { description: OK } }
  /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
        (`nexora_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 `nexora_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:
    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" }
      - { 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)" }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      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}/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.
          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/nodes/{id}/outbounds:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [node-mappings]
      summary: A node's extra outbounds
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Outbound" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [node-mappings]
      summary: Add an outbound to a node
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/Outbound" } } }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Outbound" } } } } }
  /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 an outbound (pool or per-node)
      responses: { "200": { description: Deleted } }
  /api/nodes/{id}/endpoints:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [node-mappings]
      summary: A node's endpoints (wireguard/tailscale/warp)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Endpoint" } } } } }
    post:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [node-mappings]
      summary: Add an endpoint to a node (live, no restart)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } } } }
  /api/endpoints/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    put:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Update a pool endpoint
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } } }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Endpoint" } } } } }
    delete:
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      tags: [pools]
      summary: Delete an endpoint (pool or per-node)
      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: >
        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
        `nexora-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 `nexora-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 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 `nexora-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 `nexora-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, or a secret key that is never writable here, 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. 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" }

  /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/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.
        Admin sessions are in-memory and do not survive the restart.
      responses:
        "200": { description: Restarting }
        "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. 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` / `NEXORA_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`, `no_admins`, `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/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/nexora-addon.json` under the address, checks
        its shape, its `api` version (0 and 1) and its signature against the
        keys the panel trusts — Nexora'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.nexora-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.
      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.
      responses:
        "200": { description: Removed }
        "404": { $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; 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-Nexora-Signature: t=<unix>,v1=<hex>`, the hex being
        HMAC-SHA256(secret, "<unix>.<body>")), carry `X-Nexora-Event` and
        `X-Nexora-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 `nexora_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), `no_admins` (the archive holds no admin
            accounts, so restoring it would leave a panel nobody can log into),
            `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`, `no_admins`, `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)
      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/Token" } } } } }

  /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: nexora_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. Precedence: the relay node's own routing.final wins,
            then this tunnel, then the template's final.
        finalReason:
          type: string
          readOnly: true
          enum: [disabled, noOrigin, shared, nodeFinal]
          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. nodeFinal — the relay carries its own final, which wins
            deliberately and is 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
                NEXORA_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 NEXORA_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." }
        subDomain: { type: string, description: "Reseller: which of the configured sub_domain entries this reseller's customers are published under (empty = the first). 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." }
        used: { type: integer, format: int64, readOnly: true, description: "Reseller: traffic its users burned in the current period" }
        users: { type: integer, readOnly: true, description: "Reseller: how many users it owns" }
        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-Nexora-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`, `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 }
        templateIds: { type: array, items: { type: integer } }
      additionalProperties: false
    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
            nexora-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: "Reseller (role resale) that owns this user; 0 = owned by the panel. Resellers may only see and manage their own users, and always create users for themselves." }
        allTemplates: { type: boolean, description: "Provision the user on every template (ignores templateIds)" }
        templateIds: { type: array, items: { type: integer }, description: "Explicit template membership (used when allTemplates is false); absent on create keeps legacy all-templates behaviour" }
        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 }
        routing: { $ref: "#/components/schemas/JSON" }
        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/nexora-vpn/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 }
        nodeId: { type: integer, nullable: true, description: "null = pool item; set = per-node manual" }
        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 }
    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: Nexora, development key, or developer key of <the directory listing's developer> — never a developer key's bare name" }
        signedByKind: { type: string, enum: [nexora, 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: Nexora, development key, or developer key of <the directory listing's developer>" }
        signedByKind: { type: string, enum: [nexora, 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.nexora-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: [nexora, 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. None of them blocks a
        restore; each is a decision for the operator.
      properties:
        code:
          type: string
          enum: [no_admins, hwid_mismatch, mtls_changed, node_pin_changed, newer_panel, different_panel, no_history, no_audit]
          description: >
            `no_admins` — the archive holds no main-admin (sudo) account. This
            is the one advisory a restore also enforces (400 `no_admins`), and
            the preview applies the same test so the two cannot disagree.
            `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.
            `newer_panel` — written by a newer build than this one.
            `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, no_admins, 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" }
    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 }
        nodeId: { type: integer, nullable: true, description: "null = pool item; set = per-node manual" }
        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 }
    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" }
    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"
