openapi: 3.1.0

info:
  title: CitexNode API
  version: "1.0.0"
  summary: Free game-server and Discord-bot hosting, credit funded.
  description: |
    The HTTP surface of CitexNode. Every rule this document describes -- plan
    limits, credit costs, ownership scoping -- is enforced in
    `api/internal/service`; the transport layer only reads the request, calls one
    service method and renders the result. Nothing in a client can bypass a
    check by calling a different endpoint.

    **Error contract.** Failures never carry English prose as their meaning. They
    carry a stable `code` the frontend translates, plus a `details` map used to
    fill placeholders (limits, costs, counts). Every error response on every
    operation is the same envelope: `{"error": {"code", "message", "details"}}`.
    See `components.schemas.Error`; the `code` enum is exhaustive, so a client can
    switch on it without a default branch that reads "unbekannter Fehler".

    **Ownership.** No endpoint reads a user id from a body or a query. The
    principal comes from the access token, and instance lookups resolve by
    `(instance_id, user_id)` in a single query -- an instance belonging to
    somebody else is a 404, never a 403, because existence is not the caller's
    business.

    **Not described here.** `GET /healthz`, `GET /readyz` and `GET /metrics` are
    mounted on the application root rather than under `/api/v1`: a liveness probe and a
    Prometheus scrape target are infrastructure and must not move when the client
    API is versioned. They are therefore outside every server URL below.

  contact:
    name: CitexNode
    url: https://citexnode.de
  license:
    name: Proprietary

servers:
  - url: https://citexnode.de/api/v1
    description: Production
  - url: http://localhost:8080/api/v1
    description: Local development

tags:
  - name: auth
    description: Registration, login, tokens, sessions and TOTP.
  - name: users
    description: The authenticated account itself -- profile, export, deletion.
  - name: credits
    description: Balance, daily claim, ledger and referrals.
  - name: instances
    description: Game servers and hosted bots -- lifecycle, power, env, usage.
  - name: schedules
    description: Cron entries on one instance -- restarts, backups and console commands.
  - name: members
    description: Sharing one instance with another account.
  - name: files
    description: The file manager of one instance, proxied to its node.
  - name: domains
    description: |
      The domain shop. Prices are the platform's own, in credits; what the
      registrar charges is never quoted to a customer and appears only in the
      operator's list, beside the margin.
  - name: kvm
    description: |
      Virtual servers. Read-only: there is no hypervisor behind the table yet,
      so nothing here starts, stops or reinstalls a machine.
  - name: backups
    description: Per-instance backups, bounded by the plan's backup slots.
  - name: databases
    description: Managed MySQL schemas attached to an instance.
  - name: tickets
    description: Support threads, replies and attachments.
  - name: catalog
    description: Plans and eggs -- the public product catalogue.
  - name: status
    description: Public platform health.
  - name: admin
    description: Operator surface. Every mutation writes an audit row.
  - name: internal
    description: Node daemon callbacks, machine-to-machine only.
  - name: realtime
    description: WebSocket tickets and the two socket endpoints.

security:
  - bearerAuth: []

paths:

  # --- auth -----------------------------------------------------------------

  /auth/register:
    post:
      tags: [auth]
      operationId: registerAccount
      summary: Create an account
      description: |
        Creates the account, grants the starter credits (100 by default, admin
        editable via `PUT /admin/credits/config`) and sends the verification
        mail. A `referral_code` is resolved here but pays nothing yet: the
        referral bonus is credited only once the invited account verifies its
        address **and** creates its first instance, because paying on signup
        funds a bot farm.

        Rate limited to 10 requests per minute per IP on top of the global IP
        bucket. Returns `email_taken` on a duplicate address and `password_weak`
        when the password fails the strength check.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: The created account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '403':
          description: |
            `registration_closed`. The operator has closed signups, which is a
            switch rather than a limit: retrying does not help and the answer
            carries no field to correct. `GET /platform` says so before the form
            is filled in, and carries the operator's note.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: registration_closed
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/login:
    post:
      tags: [auth]
      operationId: login
      summary: Exchange credentials for a token pair
      description: |
        Verifies the password, then TOTP when the account has it enabled: a
        missing `totp_code` on such an account answers `totp_required`, a wrong
        one `totp_invalid`. A suspended account is refused with
        `account_suspended` regardless of correct credentials. The response
        inlines the token pair next to the account so the dashboard renders
        without a second call.

        Rate limited to 20 requests per minute per IP. Wrong password and unknown
        address both answer `invalid_credentials`, so the endpoint cannot be used
        to enumerate addresses.

        **Guessing one account is limited separately.** The per-IP cap does not
        stop it: an attacker's answer to that is more addresses. Failures are
        counted per email address over fifteen minutes, and the count is what
        the answer costs:

        - up to four failures — no effect, that is a forgotten password
        - from the fifth — a growing delay before the answer, doubling from
          250 ms and capped at three seconds
        - from the tenth — one warning email to the account holder, at most one
          per hour
        - from the twenty-fifth — `429 too_many_attempts` with
          `retry_after_seconds`, until the window rolls

        The counter moves for addresses that have no account too, so its
        behaviour cannot be read as an answer to "does this address exist". The
        right password clears it. Wrong TOTP codes count as well; a *missing*
        `totp_code` does not, because that is the normal first half of a
        two-step sign-in.

        Nobody is locked out by this: `/auth/forgot-password` is untouched and
        is the right path for whoever genuinely cannot remember.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Token pair plus the account it belongs to.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSession'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429':
          description: |
            Either `rate_limited` (this caller asked too often) or
            `too_many_attempts` (this address has been guessed at too often,
            from anywhere). Both carry `retry_after_seconds`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/login/mobile:
    post:
      tags: [auth]
      operationId: loginMobile
      summary: Sign in from the phone apps, without a captcha
      description: |
        Identical to `POST /auth/login` in everything that decides who gets in:
        the same password check, the same TOTP step, the same per-account
        lockout, the same sign-in alert mail. It differs in one thing — there is
        no Turnstile token.

        **Why a second route rather than a flag.** Turnstile is a JavaScript
        challenge with no native SDK. On iOS and Android it can only be rendered
        inside a WebView, and that detour never worked here (see
        `docs/MOBILE-APP.md`). A route without a captcha is an honest statement
        of what the apps can do, and it keeps the website's own login behind the
        challenge instead of weakening it for everybody.

        **What stands in the captcha's place.** A client credential in the
        `X-CitexNode-App` header, and a bucket half the size of the website's:
        10 requests per minute per address instead of 20. The key ships inside
        an app binary and is therefore extractable — it raises the cost of a
        script and lets one platform be rotated after a leak, and it is not
        pretended to be more than that. The guards that actually stop a
        break-in are the same as on the web route.

        Without `APP_KEYS` configured the route answers on its bucket alone,
        which is what a checkout and the test suite need.
      security: []
      parameters:
        - name: X-CitexNode-App
          in: header
          required: false
          description: |
            The app's client key. Required when the deployment configures
            `APP_KEYS`; a missing or unknown key then answers 403
            `app_key_required`.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Token pair plus the account it belongs to.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSession'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            `app_key_required` (no recognised app key) or the same refusals the
            web route gives — `account_suspended`, `email_not_verified`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429':
          description: |
            Either `rate_limited` (10 per minute here) or `too_many_attempts`.
            Both carry `retry_after_seconds`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/register/mobile:
    post:
      tags: [auth]
      operationId: registerMobile
      summary: Create an account from the phone apps, without a captcha
      description: |
        The mobile twin of `POST /auth/register`, and the same service call
        underneath: the address must be free, the password must pass the same
        rules, the verification mail goes out the same way, and the signup
        switch is checked before anything else.

        No Turnstile, for the reason described on `/auth/login/mobile`. In its
        place the `X-CitexNode-App` key and a bucket of 5 requests per minute
        per address, half the website's.
      security: []
      parameters:
        - name: X-CitexNode-App
          in: header
          required: false
          description: The app's client key; required when `APP_KEYS` is configured.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: The account. No token pair — sign in afterwards.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '403':
          description: |
            `app_key_required` (no recognised app key) or `registration_closed`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409': { description: "The address is taken (`email_taken`)." }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/refresh:
    post:
      tags: [auth]
      operationId: refreshTokens
      summary: Rotate a refresh token
      description: |
        Rotates the refresh token and issues a new access token. Rotation
        replaces the stored hash, so a replayed old token misses inside a live
        family and revokes the entire family -- every session descended from that
        login is dropped, which is the intended answer to a stolen token.

        Rate limited to 60 requests per minute per IP. Answers `token_invalid`
        for an unknown or revoked token and `token_expired` past its lifetime.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefreshRequest'
      responses:
        '200':
          description: A fresh token pair. The old refresh token is now dead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenPair'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/logout:
    post:
      tags: [auth]
      operationId: logout
      summary: Revoke one refresh token
      description: |
        Revokes the session the refresh token belongs to. Deliberately requires
        no access token: a client whose access token has already expired must
        still be able to close its session, and the refresh token is itself the
        proof of possession. Revoking an already-revoked or unknown token is not
        an error.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefreshRequest'
      responses:
        '204':
          description: Session revoked.
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/verify-email:
    post:
      tags: [auth]
      operationId: verifyEmail
      summary: Confirm an email address
      description: |
        Consumes the single-use token from the verification mail and stamps
        `email_verified_at`. Only the token's hash is stored, so a database leak
        cannot be replayed here. A used, unknown or expired token answers
        `token_invalid` / `token_expired`. Verification is what unlocks the
        endpoints guarded by `verifiedRequired` -- creating instances, renewing,
        boosting, backups and databases.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TokenRequest'
      responses:
        '204':
          description: Address verified.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/passkeys:
    get:
      tags: [auth]
      operationId: listPasskeys
      summary: The account's own passkeys
      responses:
        '200':
          description: Every key on this account, newest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Passkey'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/passkeys/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: [auth]
      operationId: renamePasskey
      summary: Rename a passkey
      description: The only field a person edits. A key belonging to somebody else answers `not_found`, so this cannot probe for them.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 64, example: Laptop }
      responses:
        '204': { description: Renamed. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [auth]
      operationId: deletePasskey
      summary: Remove a passkey
      description: |
        Refuses the last way back into the account with `409
        last_sign_in_method` -- the same rule that guards disconnecting a
        provider, counted in the same place. An account whose only credential is
        one passkey, deleting that passkey, is an account nobody can open again.
      responses:
        '204': { description: Removed. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The last remaining sign-in method; set a password first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/passkeys/register/begin:
    post:
      tags: [auth]
      operationId: beginPasskeyRegistration
      summary: Start adding a passkey
      description: |
        Returns the challenge and the creation options for
        `navigator.credentials.create`. Resident keys are required, so signing in
        later needs no address -- the key itself says whose it is. Credentials
        already on the account are excluded, so pressing "add" twice on one
        device offers to replace rather than silently writing a second identical
        row.

        The challenge lives for five minutes and is spent by the finish call.
      responses:
        '200':
          description: The challenge and the options.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PasskeyChallenge' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: This deployment has no relying party configured (`passkey_unavailable`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/passkeys/register/finish:
    post:
      tags: [auth]
      operationId: finishPasskeyRegistration
      summary: Store the new passkey
      description: |
        Verifies the authenticator's answer against the challenge named by
        `handle` and stores the public half. Only the public half exists here:
        the private key never leaves the device, which is why a leak of this
        table is worth nothing to whoever steals it.

        The handle alone is not authority over which account the key lands on --
        the session it names must belong to the caller.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PasskeyFinishRequest' }
      responses:
        '201':
          description: The stored key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Passkey' }
        '400':
          description: The ceremony was refused (`passkey_failed`), or the challenge expired (`token_expired`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: This deployment has no relying party configured (`passkey_unavailable`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/passkeys/login/begin:
    post:
      tags: [auth]
      operationId: beginPasskeyLogin
      summary: Start signing in with a passkey
      description: |
        No address, no username, nothing to enumerate: the answer is identical
        for every caller, which is what keeps this from becoming a way to ask
        whether an address has an account here.

        The challenge lives for five minutes and is spent by the finish call.
      security: []
      responses:
        '200':
          description: The challenge and the request options.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PasskeyChallenge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: This deployment has no relying party configured (`passkey_unavailable`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/passkeys/login/finish:
    post:
      tags: [auth]
      operationId: finishPasskeyLogin
      summary: Sign in with a passkey
      description: |
        Verifies the assertion and hands back a token pair through the same door
        a password login uses, so the sign-in mail and the unfamiliar-country
        check apply here too.

        A signature counter that fails to advance is logged as a possible clone
        and does not refuse the sign-in: plenty of authenticators keep the
        counter at zero forever, and refusing on it would lock out working keys.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PasskeyFinishRequest' }
      responses:
        '200':
          description: The token pair with the account inlined.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthSession' }
        '400':
          description: The ceremony was refused (`passkey_failed`), or the challenge expired (`token_expired`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The account is suspended.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: This deployment has no relying party configured (`passkey_unavailable`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/oauth:
    get:
      tags: [auth]
      operationId: listOAuthProviders
      summary: Which sign-in providers this deployment accepts
      description: |
        The login page draws exactly these buttons. Public: the answer is which
        companies this platform accepts a sign-in from, which the login page
        shows anyway. Absent entirely when nothing is configured.
      security: []
      responses:
        '200':
          description: The configured provider names, sorted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  providers:
                    type: array
                    items: { type: string, enum: [google, apple, discord] }
              example: { providers: [apple, google] }
  /auth/oauth/{provider}:
    parameters:
      - name: provider
        in: path
        required: true
        schema: { type: string, enum: [google, apple, discord] }
        description: A provider the deployment has credentials for. Unconfigured providers are not mounted at all.
    get:
      tags: [auth]
      operationId: startOAuth
      summary: Begin a sign-in with a provider
      description: |
        Answers `302` to the provider's authorisation page and sets a
        short-lived, httpOnly state cookie. Not a JSON endpoint: a browser walks
        it, not a client.

        The state cookie is `SameSite=None` because Apple returns by form POST
        from its own origin, and a Lax cookie would not be sent back.
      security: []
      responses:
        '302':
          description: Redirect to the provider.
        '404':
          description: No such provider, or it has no credentials here.
  /auth/oauth/{provider}/callback:
    parameters:
      - name: provider
        in: path
        required: true
        schema: { type: string, enum: [google, apple, discord] }
    get:
      tags: [auth]
      operationId: completeOAuthGet
      summary: Return from a provider (Google, Discord)
      description: |
        Verifies the state cookie, trades the code for a profile and answers
        `302` to the dashboard with the same two session cookies a password
        login writes. Any failure redirects to the login page with an `oauth`
        query parameter naming the reason.
      security: []
      responses:
        '302':
          description: Dashboard on success, login page with a reason on failure.
    post:
      tags: [auth]
      operationId: completeOAuthPost
      summary: Return from Apple
      description: |
        The same handler. Apple switches to `response_mode=form_post` as soon as
        a scope is requested, and it requests name and email, so the code and
        state arrive in a form body instead of the query.
      security: []
      responses:
        '302':
          description: Dashboard on success, login page with a reason on failure.
  /auth/resend-verification:
    post:
      tags: [auth]
      operationId: resendVerification
      summary: Send the verification mail again
      description: |
        Always answers 204, whether or not the address exists and whether or not
        sending succeeded. The service-level error is discarded on purpose: any
        distinction visible here would turn the endpoint into an address
        oracle. Rate limited to 5 requests per minute per IP.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
      responses:
        '204':
          description: Accepted. Says nothing about whether the address exists.
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /auth/forgot-password:
    post:
      tags: [auth]
      operationId: requestPasswordReset
      summary: Start a password reset
      description: |
        Mails a single-use reset token. Like resend-verification it answers 204
        unconditionally so the response cannot be used to test whether an address
        is registered. Rate limited to 5 requests per minute per IP.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
      responses:
        '204':
          description: Accepted. Says nothing about whether the address exists.
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /auth/reset-password:
    post:
      tags: [auth]
      operationId: resetPassword
      summary: Set a new password from a reset token
      description: |
        Consumes the reset token, applies the password strength check
        (`password_weak` on failure) and revokes every existing session of the
        account -- a reset is the recovery path from a compromise, so leaving the
        attacker's refresh tokens alive would defeat it. Rate limited to 10
        requests per minute per IP.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordRequest'
      responses:
        '204':
          description: Password replaced, all sessions revoked.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/session-alert:
    post:
      tags: [auth]
      operationId: resolveSessionAlert
      summary: Act on a button in a sign-in mail
      description: |
        Every sign-in sends a mail carrying a single-use token. This endpoint
        spends it: `device` revokes the sign-in the mail is about, `all` revokes
        every session of the account.

        `device` revokes the whole refresh-token family, not the one row. A
        session rotates its token on every refresh, so by the time somebody reads
        the mail the row named in it is usually already retired and its live
        successor is the thing still signed in -- revoking a single id would
        report success and change nothing.

        POST only, and never a GET. Mail clients and link scanners fetch every
        URL in a message before a human sees it, so a sign-out link that acted on
        GET would sign customers out of accounts nobody attacked. The page behind
        the link asks first and posts the answer.

        A token whose session has already been pruned or revoked answers 204: the
        outcome the reader wanted is the outcome they have. Rate limited to 20
        requests per minute per IP.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionAlertRequest'
      responses:
        '204':
          description: The action was carried out, or there was nothing left to do.
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/email-change/confirm:
    post:
      tags: [auth]
      operationId: confirmEmailChange
      summary: Confirm a new address from the link sent to it
      description: |
        Completes a change started with `POST /me/email`. The token was read out
        of the new mailbox, which is the proof this endpoint wants — no session
        is needed or used.

        On success the address moves, the account is marked verified (the token
        came out of that mailbox, so a fresh verification round would be
        ceremony), **every session is revoked**, and the old address gets a final
        notice. Sessions go because the address is the recovery path: a change to
        it is exactly as significant as a password change, and if this was a
        takeover that got as far as confirming, the attacker's session goes too.

        Answers `token_invalid` when the change was cancelled between the mail
        and the click, and `email_taken` when somebody else claimed the address
        while the link sat in an inbox — the unique index is the real arbiter,
        not the check made when the change was requested.

        POST and never GET: a mail client that prefetches links would otherwise
        complete an address change before anybody had read the message.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        '204': { description: The address was changed and every session revoked. }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '409':
          description: Somebody else took the address while the link was waiting.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/email-change/revoke:
    post:
      tags: [auth]
      operationId: revokeEmailChange
      summary: Cancel an address change from the link sent to the old address
      description: |
        The half that catches a takeover. Whoever asked for the change holds a
        session; this link goes to the mailbox the account has *now*, which is
        somewhere they do not.

        It clears the pending address, burns the confirmation link, and revokes
        every session. All three matter: a cancelled change whose confirmation
        link still worked would be a button that lies, and the request came from
        somebody — either the account holder, who signs in again anyway, or not,
        and then this is the click that puts the attacker out.

        POST and never GET, for the same prefetch reason as above.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        '204': { description: The change was cancelled and every session revoked. }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/public-key:
    get:
      tags: [auth]
      operationId: getPublicKey
      summary: Fetch the access-token verification key
      description: |
        Returns the signer's public key as a PEM-encoded SPKI block, so a sidecar
        can verify access tokens without ever holding the private key.

        Named `public-key` and not `jwks` on purpose: this is PEM, not a JWK set,
        and a `/jwks` path answering with PEM would break the first library that
        trusts the name.
      security: []
      responses:
        '200':
          description: PEM-encoded public key.
          content:
            text/plain:
              schema:
                type: string
              example: |
                -----BEGIN PUBLIC KEY-----
                MCowBQYDK2VwAyEAG9d3mK1sM0pQxT7bYh5rN2vC8kQwZ4uJ1sXn6PbLdTk=
                -----END PUBLIC KEY-----
        '500': { $ref: '#/components/responses/Internal' }

  /auth/change-password:
    post:
      tags: [auth]
      operationId: changePassword
      summary: Change the password of the signed-in account
      description: |
        Requires the current password (`invalid_credentials` on mismatch) and
        applies the strength check to the new one (`password_weak`). Guarded by
        `authRequired` and the per-user rate bucket. Sessions other than the
        caller's are revoked, so a password change ejects anyone else holding a
        token for this account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangePasswordRequest'
      responses:
        '204':
          description: Password changed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/sessions:
    get:
      tags: [auth]
      operationId: listSessions
      summary: List the account's refresh-token sessions
      description: |
        Every live login of the signed-in account, with the user agent and IP it
        was created from, so a user can recognise a device they do not own. Only
        the caller's own sessions are ever returned -- the user id comes from the
        token. Token hashes and family ids are never serialised.
      responses:
        '200':
          description: The caller's sessions, newest activity first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Session'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [auth]
      operationId: revokeAllSessions
      summary: Revoke every session
      description: |
        Signs the account out everywhere, including the caller's own session. The
        access token already issued stays valid until it expires -- it is
        stateless -- but no new one can be minted, so the blast radius is the
        remaining access-token TTL.
      responses:
        '204':
          description: All sessions revoked.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/sessions/{id}:
    delete:
      tags: [auth]
      operationId: revokeSession
      summary: Revoke one session
      description: |
        Revokes a single session. Scoped to the caller: a session id belonging to
        another account answers `not_found`, so the endpoint cannot be used to
        probe for foreign session ids or to sign someone else out.
      parameters:
        - name: id
          in: path
          required: true
          description: Session id from `GET /auth/sessions`.
          schema:
            type: string
          example: 6d1a2c4e-9f3b-4a17-8c0d-2b5e7a91f4c3
      responses:
        '204':
          description: Session revoked.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/totp/begin:
    post:
      tags: [auth]
      operationId: beginTOTP
      summary: Start TOTP enrolment
      description: |
        Generates a secret and the `otpauth://` URI the authenticator app scans.
        The secret is stored sealed (AES-GCM) but **not** yet active: TOTP is only
        enforced at login once `POST /auth/totp/confirm` proves the user can
        produce a code. Calling this on an account that already has TOTP enabled
        answers `totp_already_enabled`, so an attacker with a live session cannot
        silently re-enrol a device of their own.
      responses:
        '200':
          description: Enrolment material. Show the URI as a QR code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TOTPSetup'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/totp/confirm:
    post:
      tags: [auth]
      operationId: confirmTOTP
      summary: Activate TOTP
      description: |
        Verifies one code against the pending secret and flips `totp_enabled`.
        Until this succeeds the account logs in with a password alone, which is
        what stops a half-finished enrolment from locking a user out of their own
        account. A wrong or stale code answers `totp_invalid`.

        Only a code from the authenticator app is accepted here. This is the one
        place a recovery code must not work: there are none yet, and the batch
        this call hands out is the batch such a code would have come from.

        **The response body carries the account's recovery codes, and this is
        the only time they are ever readable.** What is stored is their hashes,
        so no endpoint and no operator can produce them again. A client that
        does not show them here has silently given the account no way back in
        from a lost phone.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TOTPRequest'
      responses:
        '200':
          description: TOTP is now required at login, and here are the recovery codes, once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecoveryCodes'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/totp/recovery-codes:
    post:
      tags: [auth]
      operationId: regenerateRecoveryCodes
      summary: Mint a new batch of recovery codes
      description: |
        Replaces every remaining code with ten fresh ones and answers with them
        in the clear, once. The old batch stops working the moment this returns.

        It asks for the password **and** a second factor, the same pair
        disabling asks for, because the two hand over the same thing: somebody
        holding a fresh batch no longer needs the phone, so a borrowed session
        must not be enough to walk away with it.

        The `code` may be an app code or one of the account's remaining recovery
        codes. Accepting the latter is the point: somebody down to their last
        code and without their phone has to be able to refill, and the
        alternative is the support ticket this whole feature exists to avoid.

        `totp_not_enabled` when the account has no second factor -- there is
        nothing to recover from, and codes without a factor to bypass would be a
        second password.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TOTPRequest'
      responses:
        '200':
          description: The new codes. Not retrievable afterwards.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecoveryCodes'
        '401':
          description: Wrong password (`invalid_credentials`), or no session.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The account has no second factor (`totp_not_enabled`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /auth/totp/disable:
    post:
      tags: [auth]
      operationId: disableTOTP
      summary: Turn TOTP off
      description: |
        Requires **both** the account password and a current code: a stolen
        session alone must not be able to strip the second factor, and neither
        must a stolen phone. Clears the sealed secret and `totp_enabled`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TOTPDisableRequest'
      responses:
        '204':
          description: TOTP disabled.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- users ----------------------------------------------------------------

  /me/avatar:
    put:
      tags: [account]
      operationId: setAvatar
      summary: Upload the account's picture
      description: |
        The request body is the image itself, not a multipart form -- the same
        shape the server icon uses, and for the same reason: one file, no
        fields, nothing to parse before the bytes.

        PNG or JPEG, decided by decoding rather than by the file name. Whatever
        arrives is re-encoded as a 256x256 PNG, which is what makes serving it
        back safe: the bytes handed to browsers are ones this API wrote from a
        decoded image, so anything smuggled inside a comment segment does not
        survive the round trip.

        Answers the profile rather than a bare URL, so the page that uploaded it
        re-renders from one response. `avatar_url` then points at
        `/api/v1/avatars/{id}` with a version parameter that changes on every
        upload -- the path never does, and without the parameter a browser would
        keep showing the old picture at the one moment somebody looks.
      requestBody:
        required: true
        content:
          image/png:
            schema:
              type: string
              format: binary
          image/jpeg:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: The profile, with the new `avatar_url`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Profile'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413':
          description: Larger than 4 MB (`limit_file_size_exceeded`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/Validation' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [account]
      operationId: deleteAvatar
      summary: Remove the account's picture
      description: |
        Deletes the file and clears the column, which is how an account goes
        back to its initials. Deleting one that is not there succeeds: the state
        afterwards is the state that was asked for.
      responses:
        '204':
          description: Removed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /avatars/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: The account id.
        schema: { type: string, format: uuid }
    get:
      tags: [account]
      operationId: getAvatar
      summary: An account's picture
      description: |
        Unauthenticated, and it has to be: the team page, a ticket thread and an
        instance's member list all render other people's pictures, and a browser
        fetching an `<img>` carries no bearer. Nothing is disclosed that those
        pages do not already show.

        Cached for a year. The URL on the profile carries a version parameter
        that changes on every upload, so the path is safe to treat as immutable.
      security: []
      responses:
        '200':
          description: The picture, 256x256 PNG.
          content:
            image/png:
              schema:
                type: string
                format: binary
        '404':
          description: No picture for this account (`not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /me:
    get:
      tags: [users]
      operationId: getProfile
      summary: The signed-in account
      description: |
        The shape the dashboard header and settings page read: the account row
        plus the three numbers that would otherwise cost three extra calls --
        credit balance, open ticket count and instance count. Acts on the
        principal in the token; there is no user id to pass and therefore no
        ownership check to get wrong.
      responses:
        '200':
          description: Profile of the caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Profile'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    patch:
      tags: [users]
      operationId: updateProfile
      summary: Update display name, locale or avatar
      description: |
        A sparse patch. Every field is nullable and optional, and the service
        distinguishes "key absent" (leave alone) from "key present and empty"
        (clear the value) -- so sending an empty avatar_url removes the avatar
        while omitting the key keeps it. Email and role are not editable here;
        the role only moves through POST /admin/users/{id}/role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProfileUpdateRequest'
      responses:
        '200':
          description: The updated account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [users]
      operationId: deleteAccount
      summary: Delete the account and every instance on it
      description: |
        Irreversible. Tears down every instance the account owns on its node and
        drops the row; nothing restores it.

        The body must echo the account's own email address back in `confirm`,
        compared after the same normalisation the login path uses. A mismatch is
        a 422 naming field `confirm`, rule `equals`, param `email`, which is what
        turns a stray or forged request into a rejected form instead of a wiped
        account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeleteAccountRequest'
      responses:
        '204':
          description: Account and all its instances deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /me/onboarding:
    post:
      tags: [me]
      operationId: submitOnboarding
      summary: Name, birth date and address
      description: |
        The onboarding data every invoice needs: first and last name, birth
        date (past, ISO date), street, postal code, city; country defaults to
        Deutschland. The dashboard demands this before anything else — for
        new accounts right after e-mail verification and for existing ones on
        their next visit. Answers the updated profile.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [first_name, last_name, birth_date, street, postal_code, city]
              properties:
                first_name: { type: string }
                last_name: { type: string }
                birth_date: { type: string, format: date }
                street: { type: string }
                postal_code: { type: string }
                city: { type: string }
                country: { type: string }
      responses:
        '200': { description: The updated profile. }
        '422': { description: A field missing or the date implausible. }

  /me/email:
    post:
      tags: [users]
      operationId: requestEmailChange
      summary: Ask to move the account to another address
      description: |
        Records the new address as pending, mails a confirmation link to it and a
        warning with a cancel link to the address the account has now. **Nothing
        about the account changes here** — a request nobody confirms leaves it
        exactly where it was, rather than bound to a mailbox that may belong to
        nobody.

        The password is required even though the caller is authenticated. The
        address is the account's recovery path, so an unguarded change is the
        shortest takeover there is: hold a session for a minute, point the
        address at yourself, reset the password at leisure. An account created
        through a provider has no password to check and is refused with
        `invalid_credentials` and `reason: no_password`; it has to set one first.

        An address another account already holds answers `email_taken` before any
        mail is sent, so this cannot be used to send mail to arbitrary people or
        to find out which addresses have accounts here.

        Rate limited to 5 requests per minute per IP.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_email, password]
              properties:
                new_email: { type: string, format: email, example: neu@example.de }
                password: { type: string, format: password }
      responses:
        '204': { description: Both mails are on their way; the account is unchanged. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: Another account already has that address.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [users]
      operationId: cancelEmailChange
      summary: Drop a pending address change
      description: |
        The same cancellation as the link in the warning mail, for somebody who
        simply mistyped and is still signed in. Both links are invalidated.
        Unlike the mailed cancellation this does **not** revoke sessions: the
        caller is the account holder, already here, and signing them out for
        fixing their own typo would be a punishment for using the form.
      responses:
        '204': { description: No change is pending any more. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/Internal' }

  /me/activity:
    get:
      tags: [users]
      operationId: listAccountActivity
      summary: What happened lately, across everything this account can see
      description: |
        The same rows as `GET /instances/{id}/activity`, for every instance the
        caller owns or was invited to, newest first. Each row names the instance
        it belongs to; without that a mixed feed is a list of sentences with no
        subject.

        No address is ever included here, not even for an owner who would see
        one on a single instance's log. This is the glance surface on the
        overview, and a feed that spelled out an address per row would put the
        list of invited people on the first page after logging in.

        An account with no instances gets an empty list and a total of zero
        rather than everybody else's rows.
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 10, maximum: 50 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: Recent activity across the account.
          content:
            application/json:
              schema:
                type: object
                required: [items, total]
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/ActivityEntry'
                  total: { type: integer, format: int64, example: 42 }
                  limit: { type: integer, example: 10 }
                  offset: { type: integer, example: 0 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /me/oauth/{provider}:
    parameters:
      - name: provider
        in: path
        required: true
        schema: { type: string, enum: [google, apple, discord] }
    delete:
      tags: [me]
      operationId: disconnectOAuthProvider
      summary: Disconnect a linked sign-in
      description: |
        Removes the link between this account and a provider account.

        Refuses when it would be the last way in: an account with no password
        and only this provider would be unreachable afterwards, and no reset
        mail helps, because a reset sets a password on an account that has one.
        Answers `409 last_sign_in_method` in that case.
      responses:
        '204':
          description: Disconnected.
        '404':
          description: This account has no such provider linked.
        '409':
          description: It is the only remaining way to sign in.
  /me/support-pin:
    get:
      tags: [account]
      operationId: supportPIN
      summary: The live support PIN, if there is one
      description: |
        An eight-digit code the account holder reads out when they contact
        support, so the person on the other end can tell an account holder from
        somebody who knows their address. It is readable again and again by its
        owner, which is why it is sealed at rest rather than hashed.

        An empty body means there is none or it expired. That is the normal
        state: no PIN exists until somebody decided to hand one out.
      responses:
        '200':
          description: The PIN, or an empty object.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupportPIN'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [account]
      operationId: newSupportPIN
      summary: Generate a support PIN
      description: |
        Replaces whatever was there, so the previous code stops resolving
        immediately. Valid for 24 hours; a PIN that lived forever would be a
        live credential in whatever screenshot somebody took in March.

        Writes a `user.support_pin_new` row, which the account holder reads in
        their own activity feed.
      responses:
        '200':
          description: The new PIN.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupportPIN'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [account]
      operationId: revokeSupportPIN
      summary: Take the PIN back before it expires
      description: |
        The half of consent that would otherwise be missing: handing a code out
        has to be undoable by the person who handed it out.
      responses:
        '204':
          description: Gone. `POST /admin/support-pin` stops resolving it at once.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /team/{slug}:
    parameters:
      - name: slug
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [catalog]
      operationId: getTeamMember
      summary: One team member, by the slug of their name
      security: []
      responses:
        '200':
          description: The member with their own words (short line, bio, since).
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /me/team-profile:
    put:
      tags: [users]
      operationId: updateTeamProfile
      summary: What this member says about themselves on /team
      description: Only an account that carries a team role may write one; everybody else is refused.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                short: { type: string, maxLength: 140 }
                bio: { type: string, maxLength: 4000, description: Markdown. }
                since: { type: string, description: 'YYYY-MM-DD or empty.' }
      responses:
        '204':
          description: Saved.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /me/export.pdf:
    get:
      tags: [users]
      operationId: exportAccountPDF
      summary: The same export as a readable PDF
      description: Art. 15 GDPR, the copy a person reads; `/me/export` is the machine-readable one.
      responses:
        '200':
          description: The export as a PDF download.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /me/export:
    get:
      tags: [users]
      operationId: exportAccount
      summary: Download every stored record of the account
      description: |
        Art. 15 / Art. 20 GDPR. Serves the account, its instances, its ledger and
        its tickets as one JSON document, sent as an attachment rather than
        inline so a browser saves the file instead of painting a whole account
        into a tab. The filename carries the export date.
      responses:
        '200':
          description: The export document, as a download.
          headers:
            Content-Disposition:
              description: Attachment, filename citexnode-export-YYYY-MM-DD.json
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- credits --------------------------------------------------------------

  /credits:
    get:
      tags: [credits]
      operationId: getCredits
      summary: Balance, daily-claim state and current prices
      description: |
        Backs the whole credits page in one request, so the balance, the claim
        button's state and the prices rendered next to it all come from the same
        instant instead of three racing calls.

        `economy` is a deliberate projection of the operator's credit config: it
        carries the numbers a member needs to make a decision (daily bonus,
        referral bonus, boost size, duration and price) and none of the operator
        knobs (starter grant, max boosts per instance) that live on
        GET /admin/credits/config.
      responses:
        '200':
          description: The caller's credit state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditsOverview'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /credits/daily:
    post:
      tags: [credits]
      operationId: claimDailyCredits
      summary: Claim the daily credit bonus
      description: |
        Credits the configured daily bonus (10 by default) once per cooldown
        window (24h by default). The cooldown is enforced by a conditional
        UPDATE inside the same transaction that writes the ledger row, so two
        parallel claims cannot both pass -- the loser gets
        `daily_already_claimed`. A double-clicked claim is idempotent behind a
        unique index and returns the original transaction the second time.

        The response carries the ledger row and the balance it produced, so the
        client needs no follow-up read.
      responses:
        '200':
          description: The claim transaction and the resulting balance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditsClaimResult'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /credits/ledger:
    get:
      tags: [credits]
      operationId: listCreditLedger
      summary: The account's credit ledger
      description: |
        The append-only ledger, newest first, scoped to the caller -- the filter's
        user id is pinned from the token and never read from the query. There is
        no balance column anywhere: the balance is derived from these rows and
        cached, and every row stores the balance after it so a single line
        explains itself in the UI.
      parameters:
        - name: type
          in: query
          required: false
          description: Restrict to one transaction type.
          schema:
            $ref: '#/components/schemas/CreditTransactionType'
          example: renewal
        - $ref: '#/components/parameters/Limit25'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: One page of ledger rows.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditTransactionPage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /credits/referral:
    get:
      tags: [credits]
      operationId: getReferralInfo
      summary: Referral code, share link and payout so far
      description: |
        The caller's referral code, the ready-made share link built from the
        configured web URL, how many accounts signed up with it and how many
        credits it has actually paid out.

        `invited` counts signups; `earned` counts payouts, and the two differ on
        purpose -- a referral pays only once the invited account verifies its
        address and creates an instance.
      responses:
        '200':
          description: Referral state of the caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReferralInfo'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- catalog and status ---------------------------------------------------

  /plans:
    get:
      tags: [catalog]
      operationId: listPlans
      summary: The public plan catalogue
      description: |
        Every active plan, with the limits it grants. Unauthenticated and
        unpaginated on purpose: the pricing page and every creation form render
        their numbers from these rows, and a client forced to page -- or to log
        in -- would end up shipping a hardcoded copy of the catalogue instead.
        Nothing in the frontend hardcodes a limit.

        Only active rows are served here. A retired plan stays visible on
        GET /admin/plans so an operator can find it and edit it back.
      security: []
      parameters:
        - name: kind
          in: query
          required: false
          description: Restrict to one product family. Absent means both.
          schema:
            $ref: '#/components/schemas/Kind'
          example: game
      responses:
        '200':
          description: Active plans, in sort order.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Plan'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /team:
    get:
      tags: [catalog]
      operationId: listTeam
      summary: The people behind the platform
      description: |
        Everyone who holds a team role, in role order. Unauthenticated, like the
        rest of the catalogue: the /team page renders straight from this.

        It carries a display name, an avatar and the role's own words, and
        nothing else -- no address, no account id. Suspended and deleted accounts
        are left out: the page answers "who runs this platform", and somebody who
        cannot sign in is not currently an answer to that.

        An empty array is the normal state of a platform where no role has been
        handed out yet, and the page says so rather than inventing an entry.
      security: []
      responses:
        '200':
          description: The team, in role order.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TeamMember'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /eggs:
    get:
      tags: [catalog]
      operationId: listEggs
      summary: The public launch-template catalogue
      description: |
        Every active egg -- a launch template naming the runtime, image and start
        command an instance is created from. Adding a game or a bot runtime is a
        row here, not a code change. `min_ram_mb` is what a creation form checks
        an egg against before it lets a plan be picked.
      security: []
      parameters:
        - name: kind
          in: query
          required: false
          description: Restrict to one product family. Absent means both.
          schema:
            $ref: '#/components/schemas/Kind'
          example: bot
      responses:
        '200':
          description: Active eggs, in sort order.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Egg'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /eggs/{key}/versions:
    get:
      tags: [catalog]
      operationId: listEggVersions
      summary: The versions this template can be installed as
      description: |
        Asked of the software's own upstream -- PaperMC, Mojang, Purpur, Fabric,
        Forge, NeoForge, md_5's CI -- rather than answered from a list in this
        repository, which would rot. Nothing in the platform hardcodes a
        Minecraft version.

        Unauthenticated, like the rest of the catalogue: the creation form and
        the product pages render from it and nothing here is user-specific. The
        answer is cached for ten minutes per template, so a form render is not a
        request to somebody else's infrastructure.

        Two filters are applied before the list is returned. Versions whose
        minimum Java is above the template's image are dropped, because such a
        server starts, throws `UnsupportedClassVersionError` and dies. And
        unstable versions older than the newest stable one are dropped, because
        a snapshot that a release has already superseded is never what somebody
        is looking for.

        Exactly one entry carries `recommended: true` -- what the form
        preselects. It is the newest version the upstream still supports, which
        is not always the newest version it publishes.

        A template with no upstream (every bot template) answers an empty array.
        That is not an error: its form shows no version picker.
      security: []
      parameters:
        - name: key
          in: path
          required: true
          description: The egg's `key`, as returned by `GET /eggs`.
          schema:
            type: string
          example: minecraft-purpur
        - name: java
          in: query
          required: false
          description: |
            Which rung of the template's Java ladder to answer for. Absent is
            the template's own image.

            The list depends on it: Paper on Java 8 can install 1.8.8 and Paper
            on Java 25 cannot, so a creation form that has asked somebody to
            pick a JVM has to ask again here.
          schema: { type: integer }
          example: 8
      responses:
        '200':
          description: Installable versions, newest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/EggVersion'
              example:
                - { id: '26.2', stable: true, java_minimum: 25, recommended: true }
                - { id: '26.3-snapshot-9', stable: false, java_minimum: 25, recommended: false }
                - { id: '1.21.11', stable: true, java_minimum: 21, recommended: false }
        '404':
          $ref: '#/components/responses/NotFound'
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: |
            Code `versions_unavailable`. The upstream did not answer, or nothing
            it offers runs in this template's image. Deliberately not
            `internal_error`: the outage is not ours, and an empty array would
            claim the software has no versions. The creation form shows the
            translated message with a retry, and stays usable -- a create
            without a version installs the template's own default.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /platform:
    get:
      tags: [catalog]
      operationId: platform
      summary: Whether signups are open
      description: |
        For the register page, which is the one page nobody has an account for
        yet. `registration_note` is only present when registration is closed and
        the operator wrote one.

        Deliberately its own small shape rather than the operator's settings
        row: that row will grow switches, and returning it whole would publish
        each new one by default instead of by decision.
      responses:
        '200':
          description: The public switches.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicPlatform'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /credits/invoices:
    get:
      tags: [credits]
      operationId: listInvoices
      summary: The account's invoices
      description: |
        Every paid ledger movement (top-up, order, renewal, KVM) carries a
        sequential invoice number since 2026-09-01; this lists those rows,
        newest first. The PDF is rendered on demand from the row — nothing is
        stored twice.
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 100 } }
      responses:
        '200': { description: The invoices. }

  /credits/invoices/{no}/pdf:
    get:
      tags: [credits]
      operationId: getInvoicePDF
      summary: One invoice as PDF
      description: |
        Renders the invoice for download. Owner-scoped — the number alone
        opens nothing. Issuer details and the § 19 UStG note come from the
        Impressum; no VAT is stated (Kleinunternehmerregelung).
      parameters:
        - { name: no, in: path, required: true, schema: { type: integer } }
      responses:
        '200': { description: The PDF (application/pdf, attachment). }
        '404': { description: No such invoice on this account. }

  /credits/topup:
    post:
      tags: [credits]
      operationId: startTopup
      summary: Open a payment to top up the balance
      description: |
        Opens a Stripe Checkout session or a PayPal order over `amount_cents`
        (euro cents, 500..10000) and answers where to send the browser. Nothing
        is credited here: Stripe charges on its own hosted page, PayPal charges
        only when the platform captures on return.

        Requires a verified address, like everything that consumes something
        real. `payment_unconfigured` when the chosen provider has no
        credentials stored or billing still runs on credits.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, amount_cents]
              properties:
                provider: { type: string, enum: [stripe, paypal] }
                amount_cents: { type: integer, example: 1000 }
                locale: { type: string, example: de }
      responses:
        '200':
          description: Where the browser goes to pay.
          content:
            application/json:
              schema:
                type: object
                required: [redirect_url]
                properties:
                  redirect_url: { type: string }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
  /credits/topup/confirm:
    post:
      tags: [credits]
      operationId: confirmTopup
      summary: Verify a returned payment and credit the balance
      description: |
        Called by the return page with the provider's reference (Stripe session
        id, PayPal order id). The platform asks the provider directly -- the
        browser's word counts for nothing -- captures where PayPal is involved,
        and credits idempotently on the payment id: webhook, double click and a
        reloaded page all book once.

        The reference must belong to the caller; someone pasting a foreign id
        gets `payment_failed`. `payment_pending` is money honestly still in
        flight, and support books it when it settles.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, reference]
              properties:
                provider: { type: string, enum: [stripe, paypal] }
                reference: { type: string }
      responses:
        '200':
          description: The booked top-up.
          content:
            application/json:
              schema:
                type: object
                required: [status, amount_cents, balance]
                properties:
                  status: { type: string, enum: [credited] }
                  amount_cents: { type: integer }
                  balance: { type: integer }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
  /webhooks/stripe:
    post:
      tags: [credits]
      operationId: stripeWebhook
      summary: "Stripe's callback: book a completed checkout"
      security: []
      description: |
        Unauthenticated by design -- Stripe holds no session. The
        `Stripe-Signature` header (HMAC over the raw body with the endpoint
        secret) is the authentication, verified before a byte is believed.
        Credits idempotently, so this and the return page can both fire.

        The operator registers this URL in the Stripe dashboard for the
        `checkout.session.completed` event.
      responses:
        '200':
          description: Acknowledged; Stripe stops retrying.
        '400':
          description: Signature missing, stale or wrong. Code `payment_failed`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
  /economy:
    get:
      tags: [catalog]
      operationId: getPublicEconomy
      summary: The public half of the credit economy
      description: |
        Exists so the marketing pages can state what a renewal costs and what a
        day of showing up earns without hardcoding either -- an admin who edits
        the numbers edits the pricing page with them.

        A deliberate subset of the credit config, not the config struct itself:
        returning the struct would leak every field added to it later, which is
        how an internal knob ends up on a public page by accident. Everything an
        anonymous visitor has no business knowing -- the per-instance boost
        ceiling and anything operational -- stays behind `GET /credits` and
        `GET /admin/credits/config`.
      security: []
      responses:
        '200':
          description: The publicly quotable credit numbers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicEconomy'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /kvm/{id}/live:
    get:
      tags: [kvm]
      operationId: getKVMLive
      summary: Live state of one managed machine
      description: |
        Status straight from the supplier, plus the hypervisor's live reading
        (CPU share, memory bytes, lifetime network bytes) when the monitoring
        key may read it. 409 `kvm_unmanaged` on a machine with no supplier
        service linked; 503 when the supplier does not answer.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Status and live readings. }
        '409': { description: Machine is not supplier-managed (`kvm_unmanaged`). }
        '503': { description: Supplier unreachable. }

  /kvm/{id}/traffic:
    get:
      tags: [kvm]
      operationId: getKVMTraffic
      summary: Traffic history of one managed machine
      description: |
        The supplier's per-day history (last 30 days, megabytes in/out).
        Same 409/503 semantics as /kvm/{id}/live.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Days with in/out megabytes. }
        '409': { description: Machine is not supplier-managed (`kvm_unmanaged`). }
        '503': { description: Supplier unreachable. }

  /kvm/{id}/power:
    post:
      tags: [kvm]
      operationId: powerKVM
      summary: Start, stop, restart or shut down one managed machine
      description: |
        Executes exactly one of start|stop|restart|shutdown at the supplier --
        a whitelist, nothing else on the supplier's service tree is reachable
        through this. Afterwards the stored status is refreshed best-effort.
        409 `kvm_unmanaged` without a linked service; 503 when the supplier
        refuses or does not answer.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                action: { type: string, enum: [start, stop, restart, shutdown] }
      responses:
        '200': { description: Action accepted by the supplier. }
        '409': { description: Machine is not supplier-managed (`kvm_unmanaged`). }
        '503': { description: Supplier refused or unreachable. }

  /kvm/order:
    post:
      tags: [kvm]
      operationId: orderKVM
      summary: Buy one machine from the supplier
      description: |
        The real purchase. The balance is debited first (`kvm_order` in the
        ledger), then the supplier order runs with this order's id as its
        idempotency key; any supplier failure refunds (`kvm_refund`) before the
        error leaves the API. `expected_price_cents` must equal the current
        catalogue price times months — a price move answers 409
        `kvm_price_changed` with the new number instead of silently charging
        it. Sold-out packets answer 409 `kvm_sold_out`; 402 is the balance
        falling short; 503 `kvm_order_failed` is the supplier refusing, with
        the money already back.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [packet_id, os_id, months, expected_price_cents]
              properties:
                packet_id: { type: string }
                os_id: { type: string }
                months: { type: integer, enum: [1, 3, 6, 12] }
                expected_price_cents: { type: integer }
      responses:
        '200': { description: The stored machine, supplier service linked. }
        '402': { description: Balance too low (`insufficient_credits`). }
        '409': { description: Sold out or price changed. }
        '503': { description: Supplier refused; money refunded. }

  /kvm/{id}/renew:
    post:
      tags: [kvm]
      operationId: renewKVM
      summary: Extend one machine against balance
      description: |
        Debits price × months (`kvm_renew`), extends the supplier service by
        months × 30 days, refunds on supplier failure. Days are added onto the
        later of now and the current renewal date, so renewing early never
        eats paid time.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [months]
              properties:
                months: { type: integer, enum: [1, 3, 6, 12] }
      responses:
        '200': { description: The new renewal date. }
        '402': { description: Balance too low. }
        '409': { description: Machine is not supplier-managed. }
        '503': { description: Supplier refused; money refunded. }

  /kvm/{id}/hardware:
    get:
      tags: [kvm]
      operationId: getKVMHardware
      summary: What the machine really runs on
      description: |
        The supplier's hardware sheet for this service — cores, memory, disk,
        uplink, CPU type, host, storage type, traffic allowance and DDoS
        protection. Our row is the order; this is the delivery.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: The hardware sheet. }
        '409': { description: Machine is not supplier-managed (`kvm_unmanaged`). }
        '503': { description: Supplier unreachable. }

  /kvm/{id}/network:
    get:
      tags: [kvm]
      operationId: getKVMNetwork
      summary: Addresses, gateways and rDNS
      description: |
        IPv4 addresses with gateway, netmask and rDNS, the IPv6 subnet and its
        assigned addresses — as the supplier holds them.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: The address sheet. }
        '409': { description: Machine is not supplier-managed (`kvm_unmanaged`). }
        '503': { description: Supplier unreachable. }

  /kvm/{id}/backups:
    get:
      tags: [kvm]
      operationId: getKVMBackups
      summary: Backups held at the supplier
      description: |
        The backup list for this service, read with the backup-scoped key. An
        empty list is an honest answer, not an error.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: The backups, possibly none. }
        '409': { description: Machine is not supplier-managed (`kvm_unmanaged`). }
        '503': { description: Supplier unreachable. }

  /kvm/catalog:
    get:
      tags: [catalog]
      operationId: getKVMCatalog
      summary: The KVM catalogue, priced for resale
      description: |
        The supplier's KVM packages (four lines) and installable OS images,
        with selling prices — purchase prices and the markup never leave the
        server. Cached for ten minutes; a supplier outage serves the last good
        copy for up to a day. Only a cold start during an outage answers 503
        (`kvm_catalog_unavailable`). `available` mirrors the supplier's stock:
        when they are sold out, so are we. Unauthenticated like the rest of
        the catalogue, because the public /kvm page renders from it.
      security: []
      responses:
        '200':
          description: Lines with packages, and the OS list.
        '503':
          description: No supplier answer and no cached copy yet.

  /dedicated/catalog:
    get:
      tags: [catalog]
      operationId: getDedicatedCatalog
      summary: The dedicated-server catalogue, priced for resale
      description: |
        The supplier's dedicated lines (AMD Ryzen, Intel Xeon) and the sale
        machines, with selling prices -- purchase prices and the markup never
        leave the server. Cached like the KVM catalogue; `available` mirrors
        the supplier's stock. Unauthenticated, because the public dedicated
        pages render from it.
      security: []
      responses:
        '200':
          description: Lines with packages, and the sale list.
        '503':
          description: No supplier answer and no cached copy yet.

  /status:
    get:
      tags: [status]
      operationId: getPlatformStatus
      summary: Public platform health
      description: |
        The cached node health map, aggregated into one verdict
        (operational / degraded / outage). Served from cache and never by
        touching a node: the status page has to stay up precisely when the nodes
        are not, so a slow or dead node must not make this endpoint slow or dead
        too. `checked_at` says how fresh the snapshot is.
      security: []
      responses:
        '200':
          description: Aggregated platform health.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Summary'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- instances ------------------------------------------------------------

  /instances:
    get:
      tags: [instances]
      operationId: listInstances
      summary: The caller's instances
      description: |
        Every instance owned by the caller, each one already carrying its ports,
        active boosts, effective RAM, live usage sample and renewal terms -- the
        dashboard renders its whole grid from this one call.

        An unknown `kind` is rejected rather than filtered: answering an empty
        list to a typo reads as "you have no servers", which is the wrong thing
        to tell somebody who has three.
      parameters:
        - name: kind
          in: query
          required: false
          description: Restrict to game servers or bots. Absent means both.
          schema:
            $ref: '#/components/schemas/Kind'
          example: game
      responses:
        '200':
          description: The caller's instances.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/InstanceView'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [instances]
      operationId: createInstance
      summary: Provision a game server or a bot
      description: |
        Requires a verified email address (`email_not_verified` otherwise).

        Enforced in order, cheap checks first so a rejected request never leaves
        a half-created instance on a node:

        * the name must match `^[\p{L}\p{N} _.\-]{3,32}$` (German umlauts pass);
        * the plan must be active (`plan_inactive`);
        * the caller's existing instances of that plan must be below
          `max_instances` (`limit_instances_reached`) -- a suspended instance
          still counts, it exists and occupies disk;
        * requested RAM, CPU and disk must fit the plan
          (`limit_ram_exceeded`, `limit_cpu_exceeded`, `limit_disk_exceeded`);
        * a node with room for that RAM and disk must exist
          (`node_unavailable`).

        `ram_mb` and `disk_mb` are optional: zero means "the plan's full
        allowance", which is what the creation form sends. `git_repo` applies to
        bots only. The owner is taken from the token and the IP from the
        connection -- a client-supplied owner would turn creation into a way to
        spend somebody else's allowance.

        The instance comes back in `installing`; the daemon reports the
        transition to `offline` over the events socket when the image is ready.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InstanceCreateRequest'
      responses:
        '201':
          description: The provisioned instance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Instance'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: getInstance
      summary: One instance with limits, boosts and live usage
      description: |
        Everything the detail page needs in a single call: the instance, its
        ports, its active boosts, the effective RAM those boosts add up to, the
        live usage sample and the renewal price and period.

        Resolved by `(id, owner)` in one query. An instance owned by somebody
        else answers `not_found`, not `forbidden` -- whether it exists is not the
        caller's business.
      responses:
        '200':
          description: The instance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstanceView'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    patch:
      tags: [instances]
      operationId: updateInstance
      summary: Rename an instance, or set what happens after a crash
      description: |
        The instance's two editable fields. Both are optional and independent:
        sending only `name` leaves the crash switch alone, and sending only
        `restart_on_crash` leaves the name alone. A request with neither is
        refused.

        The name is validated against the same pattern creation uses (3-32
        letters, digits, space, underscore, dot or hyphen), so a rename cannot
        smuggle in a name a creation would have refused.

        `restart_on_crash` is a tri-state on the wire: absent means "do not
        touch it", `false` means "turn it off". Owner-scoped -- it changes what
        the platform does to a server without anybody present, which is a
        disposition rather than an operation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InstanceUpdateRequest'
      responses:
        '204':
          description: Renamed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [instances]
      operationId: deleteInstance
      summary: Delete an instance now
      description: |
        The user-initiated deletion, as opposed to the scheduled one at the end
        of the grace period. Stops the container, removes it from the node,
        drops the live usage entry and soft-deletes the row. The plan slot is
        free immediately afterwards. Owner-scoped and irreversible; there is no
        restore path and no undo window.
      responses:
        '204':
          description: Instance deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }


  /instances/shared:
    get:
      tags: [instances, members]
      operationId: listSharedInstances
      summary: Instances somebody else let the caller into
      description: |
        The other half of the dashboard's list. Owned instances come from
        `GET /instances`; this returns only the ones the caller was invited to,
        never their own.

        Two calls rather than one flag on the first, because "your servers" and
        "servers you were let into" are different things to a reader: a merged
        list leaves somebody wondering why one row has no delete button.

        The rows are the same `InstanceView` the owner sees, so a member's
        detail page needs no second shape.
      parameters:
        - name: kind
          in: query
          required: false
          description: Restrict to game servers or bots. Absent means both.
          schema:
            $ref: '#/components/schemas/Kind'
          example: game
      responses:
        '200':
          description: Instances shared with the caller. Empty when nobody has invited them.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/InstanceView'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/activity:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: listInstanceActivity
      summary: What happened to this instance, and who did it
      description: |
        The audit trail of one instance, newest first, member-level.

        It reads rows that were already being written. Every instance action
        records one keyed by the instance id; what did not exist was a way for
        the people who own and share the instance to read them, which left "why
        did my server restart at four in the morning" answerable only by an
        operator.

        Member-level rather than owner-only on purpose: somebody who can stop
        the server should be able to see that a co-member stopped it. A shared
        instance where things move by themselves is worse than no sharing.

        Two things are trimmed on the way out, and both trim the same leak. The
        actor's address is shown to the owner and to nobody else, and metadata
        keys that carry an address or an account id (`email`, `member`, `ip`,
        `actor`) are dropped for everybody. Otherwise a member could read the
        list of invited addresses off the log, which is exactly what
        `GET /instances/{id}/members` refuses to hand them.

        `actor.scheduled` marks a row that no one triggered. A schedule acts as
        the owner, and without the flag the log claims the owner was at their
        keyboard at 03:00.

        Actions are the same strings the admin audit log uses --
        `instance.power`, `instance.rename`, `instance.backup_create`,
        `instance.member_add` and so on. A client renders what it knows and
        falls back to the raw string, so a new action never renders as blank.
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 25, maximum: 100 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: The instance's activity, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [items, total]
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/ActivityEntry'
                  total:
                    type: integer
                    format: int64
                    example: 137
                  limit: { type: integer, example: 25 }
                  offset: { type: integer, example: 0 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/appearance:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: getInstanceAppearance
      summary: The MOTD and whether an icon is set
      description: |
        What this server looks like in somebody's server list.

        Both settings were already reachable through the file manager -- `motd`
        is a key in `server.properties` and the icon is a file called
        `server-icon.png` -- and that is exactly the problem this replaces.
        Editing the MOTD meant finding one key in a hundred-line vendor file,
        and the icon meant knowing that Minecraft accepts a 64x64 PNG and
        nothing else, with the failure being a server that starts fine and
        shows no icon anywhere.

        `supported` is false for a runtime whose MOTD is not that key: Velocity
        and BungeeCord read their own config file, and the Bedrock server calls
        the field `server-name` and has no icon at all. The other fields are
        then absent and the page says so, rather than offering a field that
        writes into a file nothing reads.

        `motd` comes back unescaped -- a real newline for the second line, a
        real section sign where the file carries the `\u00A7` escape. The form
        edits what players see, not what the file spells.

        Member level. Somebody who can restart the server and edit its files can
        already change both of these.
      responses:
        '200':
          description: The current appearance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Appearance'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    put:
      tags: [instances]
      operationId: setInstanceMotd
      summary: Set the MOTD
      description: |
        Writes the one key and leaves the rest of `server.properties` as it was:
        every key the vendor documents, every comment, and whatever the user has
        edited by hand. Replacing the file would be this endpoint quietly
        deleting somebody's configuration.

        A newline in the value is written as an escape rather than as a real
        newline, which is what makes a two-line MOTD work; a real one would end
        the property there and leave the second half as a line the server tries
        to read as a key. Colour codes go in as section-sign characters and
        survive the round trip.

        The server reads the file once, at startup. `restart_required` on the
        GET says so, because a saved MOTD that does not appear reads as a broken
        form rather than as a server that has not been restarted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [motd]
              properties:
                motd:
                  type: string
                  maxLength: 300
                  description: What players see. Two lines separated by a newline; colour codes are kept.
                  example: "Mein Server\nJeden Abend ab 18 Uhr"
      responses:
        '204':
          description: Written.
        '400':
          description: This runtime has no MOTD of that shape (`appearance_unsupported`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/appearance/icon:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    put:
      tags: [instances]
      operationId: setInstanceIcon
      summary: Upload the server icon
      description: |
        The request body is the PNG itself, not a multipart form. At most 4 MB,
        checked before anything is decoded, because decoding is where a hostile
        image spends memory.

        PNG only, decided by decoding rather than by the file name: a JPEG
        renamed to `.png` is a file Minecraft ignores, and telling the user that
        at upload time is the whole point of this endpoint existing.

        Anything that is not 64x64 is resized to it rather than refused.
        Minecraft ignores an icon of any other size and says nothing about it
        anywhere, so refusing would answer "wrong dimensions" to almost every
        logo anybody owns -- the same dead end as the file manager, reached one
        step later.
      requestBody:
        required: true
        content:
          image/png:
            schema:
              type: string
              format: binary
      responses:
        '204':
          description: The icon is in place, 64x64.
        '400':
          description: Not a PNG, or a runtime with no icon (`appearance_unsupported`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413':
          description: Larger than the upload ceiling (`limit_file_size_exceeded`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [instances]
      operationId: deleteInstanceIcon
      summary: Remove the server icon
      description: |
        Deletes the file, which is how a server goes back to the client's
        default pack icon. Overwriting it with a blank image would leave a grey
        square instead, which is not the same thing.

        Removing an icon that is not there succeeds.
      responses:
        '204':
          description: Gone.
        '400':
          description: A runtime with no icon (`appearance_unsupported`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/sftp:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: getInstanceSFTP
      summary: How to reach this instance's files over SFTP
      description: |
        Owner-only. Answers the address, the username and whether a password
        exists -- never the password itself, which the platform stores as an
        argon2id hash and cannot recover.

        `available` is false when the node this instance sits on does not offer
        SFTP, which is the honest answer until the port is open in that node's
        firewall. The panel then says so instead of printing an address that
        refuses connections.

        Owner rather than member. A member can already read and write every file
        through the panel, so nothing is hidden from them: it is that a password
        which opens one server's directory outlives the membership that handed
        it out.
      responses:
        '200':
          description: The connection details, without a password.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SFTPAccess'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [instances]
      operationId: setInstanceSFTPPassword
      summary: Generate a new SFTP password
      description: |
        Owner-only, and it takes no body: the password is the platform's to
        generate, not the caller's to choose.

        It is in this answer and in no other. Every later read leaves the field
        empty, because what is stored is a hash -- a value the platform cannot
        recover cannot leak out of a backup either.

        Setting one for the first time and replacing a lost one are the same
        call. They have the same answer, and two buttons that do the same thing
        is one button too many.
      responses:
        '201':
          description: The connection details, with the password, once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SFTPAccess'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [instances]
      operationId: clearInstanceSFTP
      summary: Turn SFTP off for this instance
      description: |
        Owner-only. Drops the stored hash, so a client still holding the old
        password is refused at its next connect rather than at the next
        rotation.
      responses:
        '204':
          description: SFTP is off.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /internal/sftp/auth:
    post:
      tags: [internal]
      operationId: internalSFTPAuth
      summary: Judge one SFTP login attempt for a node
      description: |
        Machine-to-machine, guarded by the shared daemon token.

        A node holds no credentials of its own. When a client connects to its
        SFTP listener the daemon asks here, and the API answers with the
        instance id or with 401. That keeps the hash in one place, makes a
        revoke take effect on the next connect rather than at the next deploy,
        and means a node that is compromised holds nothing that outlives it.

        `node_id` is checked against the instance's own: a daemon token that
        leaked off one node must not open a directory on another. Every refusal
        answers 401 and nothing else -- a login probe must not be able to tell
        "no such instance" from "wrong password".
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [node_id, username, password]
              properties:
                node_id: { type: string }
                username:
                  type: string
                  description: The instance's daemon ref, which is what a node knows it by.
                password: { type: string }
      responses:
        '200':
          description: The login is good.
          content:
            application/json:
              schema:
                type: object
                required: [instance_id]
                properties:
                  instance_id: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/alerts:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: getInstanceAlertChannel
      summary: Whether this instance posts to a Discord channel
      description: |
        Owner-only, and it answers one bit: is a webhook configured.

        The URL itself never comes back. Anybody holding it can post into
        somebody's Discord, so it follows the same rule as a node token and the
        platform's own alert webhook -- write-only, and the form renders
        "connected" from a boolean rather than from the secret.

        Owner rather than member. A member can already read every one of these
        events in the activity log, so this is not about hiding them: aiming
        them at a channel the owner does not know about is a quiet way to stream
        somebody else's server off the platform, and the person who can invite
        is the person who should decide that.
      responses:
        '200':
          description: The channel's state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertChannel'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    put:
      tags: [instances]
      operationId: setInstanceAlertChannel
      summary: Point this instance at a Discord channel
      description: |
        Owner-only. The instance posts started, stopped, backed up, restored,
        reinstalled, crashed and suspended into the channel, fed from the same
        rows the activity log renders.

        Three states, because a write-only secret needs them:

        * `webhook` set to a URL replaces what is stored
        * `webhook` empty keeps it, so saving the form does not wipe a channel
          the form was never shown
        * `clear: true` removes it, which is the only way to remove it

        The URL has to start `https://discord.com/api/webhooks/` or
        `https://discordapp.com/api/webhooks/`. A typo is a channel that goes
        nowhere and nothing on the platform would notice, so the shape is
        checked before anything is stored.

        The audit row this writes carries only whether a channel is now
        configured. A log is not where a credential goes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AlertChannel'
      responses:
        '200':
          description: The channel's state afterwards.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertChannel'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/alerts/test:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [instances]
      operationId: testInstanceAlertChannel
      summary: Post one line to the channel now
      description: |
        Owner-only. The alternative is finding out the URL was pasted wrong
        during the outage it was supposed to report, which is the one moment
        nobody is in a position to debug it -- a webhook that goes nowhere is
        silent in exactly the way a working one is.

        `422 webhook_failed` is Discord refusing the post, which is the answer
        worth telling apart from a validation failure: the URL is well formed
        and the channel is gone.
      responses:
        '204':
          description: Discord accepted the message.
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: Discord refused the post.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/schedules:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [schedules]
      operationId: listSchedules
      summary: The cron entries on one instance
      description: |
        Member-level: a schedule is the answer to "why did the server restart at
        four in the morning", and somebody operating it deserves that answer.

        `last_run_at` is the guard against a double firing across a restart as
        well as a display value, and `last_error` carries the reason a schedule
        did not fire -- an unparsable expression, or an action the instance
        refused. Both are written by the runner, never by a client.
      responses:
        '200':
          description: The instance's schedules.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Schedule'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [schedules]
      operationId: createSchedule
      summary: Add a schedule
      description: |
        Owner-only. A schedule outlives the session that made it and keeps
        acting on the server every night; that is closer to owning the instance
        than to operating it, so a member may read schedules but not write them.

        Validated before anything is written, so a row in the table is always
        one the runner can read:

        * `action` must be one of `start`, `stop`, `restart`, `backup`,
          `command`;
        * `payload` is required when the action is `command`, and is the console
          line that gets sent;
        * `cron` must parse as five fields, and must not fire more often than
          every ten minutes (rule `too_often`) -- a schedule on every minute is
          a legal expression that restarts a server sixty times an hour, and
          whoever typed it almost always meant the top of the hour;
        * the plan's `schedule_slots` caps how many exist
          (`limit_schedules_reached`, with `limit` and `current` in the
          details).

        Expressions are read in Europe/Berlin, the same clock the rest of the
        product shows, so a schedule set for 03:00 fires at 03:00 in summer too.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScheduleRequest'
      responses:
        '201':
          description: The schedule as stored, with the cron expression normalised.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Schedule'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The plan's schedule slots are used up (`limit_schedules_reached`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/schedules/{scheduleId}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
      - $ref: '#/components/parameters/ScheduleId'
    put:
      tags: [schedules]
      operationId: updateSchedule
      summary: Replace a schedule
      description: |
        Owner-only, and a full replacement rather than a patch: every field in
        the body is written, so an omitted `active` disables the schedule.

        The same validation as creation, minus the slot check -- editing an
        existing row does not consume a new slot. `last_error` is cleared on a
        successful write: it belonged to the old expression, and keeping it
        would leave a red line under a schedule that was just fixed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScheduleRequest'
      responses:
        '200':
          description: The updated schedule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Schedule'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [schedules]
      operationId: deleteSchedule
      summary: Remove a schedule
      description: Owner-only. The slot is free immediately afterwards.
      responses:
        '204':
          description: Schedule removed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/members:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [members]
      operationId: listMembers
      summary: Everybody the owner invited
      description: |
        Owner-only, enforced in the service rather than by the route: a member
        listing the others would be one member harvesting the addresses of
        people the owner invited, not them. A member calling this gets the same
        `not_found` a stranger does.

        The owner is not in the list -- they are the instance's `user_id`, not a
        membership row. An address in a row is one the owner typed themselves,
        so it tells them nothing they did not already know.
      responses:
        '200':
          description: The instance's members. Empty when it has never been shared.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Member'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [members]
      operationId: addMember
      summary: Share the instance with an account
      description: |
        Owner-only, and requires a verified email address on the owner
        (`email_not_verified`).

        The invitee is addressed by the address they registered with. There is
        no invitation to accept: they appear on the server immediately and see
        it in their dashboard under the shared list. That is a deliberate
        simplification and it is stated in the UI -- the cost of an accept flow
        (a token, a mail, an expiry, a pending state in three places) buys very
        little when the alternative is that they can remove themselves at any
        time.

        Three refusals are their own codes rather than a shared 404, because
        each one has a different sentence to show:

        * `member_unknown` -- no account with that address. The owner typed an
          address and needs to know it was the address that was wrong, not the
          server.
        * `member_self` -- the address is the owner's own.
        * `member_exists` -- that account already has access.

        A member may operate the instance: power, console, files, backups. What
        stays with the owner is disposing of it -- deleting, renewing, sharing,
        and writing schedules.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MemberAddRequest'
      responses:
        '201':
          description: The new member.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Member'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: The instance is not the caller's, or no account has that address (`member_unknown`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The address is the owner's own (`member_self`) or already a member (`member_exists`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/members/{userId}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
      - $ref: '#/components/parameters/MemberUserId'
    delete:
      tags: [members]
      operationId: removeMember
      summary: Revoke access
      description: |
        Two people may call this: the owner, for anybody, and a member for
        themselves. The second is not a convenience -- somebody added without
        being asked has to be able to get out of it, and the alternative is a
        server they cannot remove from their own dashboard.

        Note the path takes the member's **user id**, not the membership row's
        id: the caller removing themselves knows their own user id and would
        otherwise have to list the members first, which is owner-only.
      responses:
        '204':
          description: Access revoked.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/power:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [instances]
      operationId: powerInstance
      summary: Start, stop, restart or kill an instance
      description: |
        Answers **202, not 200**: the service tells the daemon to act and writes
        the transitional status (`starting` / `stopping`), but the container
        reaches the target state afterwards. A 200 would promise the machine is
        already running. The client watches the events socket for the real
        transition.

        Deliberately **not** behind `verifiedRequired`: operating what a user
        already has stays open, so an unverified account can still stop a runaway
        server. A suspended instance refuses with `instance_suspended`; one
        already mid-transition refuses with `instance_busy`; `stop` and `restart`
        on an offline instance answer `instance_not_running`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PowerRequest'
      responses:
        '202':
          description: Accepted. The transition is reported over the events socket.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PowerAccepted'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/reinstall:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [instances]
      operationId: reinstallInstance
      summary: Run the template's setup again, optionally on a different version
      description: |
        This is also how a version changes: there is no separate endpoint,
        because switching from 1.21.4 to 1.21.8 and repairing a broken jar are
        the same operation on the node.

        The user's files are left alone. The world, the plugins and every edited
        config survive; what is replaced is the server software and the files
        the template's setup owns.

        Requires a verified address, like creation, because it makes a node
        download an artefact on demand.

        The instance must be off (`instance_running` otherwise) and not already
        installing (`instance_busy`). `version` is optional: empty means the
        template's recommended version. A version the provider does not list is
        rejected with `validation_failed` before anything is downloaded -- the
        list the picker shows and the artefact the node fetches come from the
        same place, and accepting a version outside it is how a server installs
        something nobody offered.

        Answers 202: the node does the work afterwards and the instance goes
        back to `installing`. The events socket reports the transition.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReinstallRequest'
      responses:
        '202':
          description: Accepted; the node reinstalls and reports the transition.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The instance is running (`instance_running`) or still installing (`instance_busy`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503':
          description: The node or the version provider is unreachable (`node_unavailable`, `versions_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /instances/{id}/renew:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [instances]
      operationId: renewInstance
      summary: Pay the plan's renewal cost and push the expiry out
      description: |
        Requires a verified address. Debits the plan's `renewal_cost` (20 credits
        on `free-game`, 10 on `free-bot`) and extends `expires_at` by the plan's
        `renewal_days` (4 and 7 respectively). `insufficient_credits` when the
        balance is short.

        Renewing **works while suspended** -- that is the point: renewing is how a
        user rescues a server, not something they are locked out of. A suspended
        instance is unsuspended and resumes on the node.

        Extension is measured from the current expiry when it is still in the
        future and from now when it has already lapsed, so renewing early is
        never a penalty. The debit and the expiry write share one database
        transaction under the idempotency key `renew:<instance>:<old expiry>`, so
        a double-clicked button charges once.
      responses:
        '200':
          description: The renewed instance, with its new expiry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Instance'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/ddos:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: instanceProtection
      summary: What stands between the internet and this server
      description: |
        Two halves, and the split is the honest part.

        The first is what the operator bought and wrote down against the node:
        a filter, the layers it covers, and a sentence for whatever does not fit
        a column. All instances on one node share it. `stated` is false when
        nobody filled it in — which a client must render as "not stated" and
        never as "unprotected", because turning a blank column into an alarming
        claim is the worse of the two mistakes.

        The second half is not here and is not missing: individual attacks,
        their kind, their duration and whether a scrubber engaged are facts the
        network provider holds. Neither AWS Shield Standard nor a dedicated
        host's standard filter reports them to us, so there is no attack list
        rather than an empty one that would read as "nothing ever happened".

        What the platform does measure itself is how much traffic reached the
        container, which is on `/instances/{id}/usage/history`.

        Readable by anybody the instance is shared with: whether the machine is
        protected is not a secret from a member.
      responses:
        '200':
          description: The protection in front of this instance's node.
          content:
            application/json:
              schema:
                type: object
                required: [node_name, node_region, provider, layers, note, stated]
                properties:
                  node_name:
                    type: string
                    description: |
                      Which machine the answer is about. Two servers on two
                      nodes give two different answers, and without this they
                      would look like a contradiction.
                    example: node1
                  node_region:
                    type: string
                    example: eu-central
                  provider:
                    type: string
                    description: Empty when not stated.
                    example: AWS Shield Standard
                  layers:
                    type: array
                    description: |
                      OSI layers as the provider spells them: 3 and 4 are the
                      network and transport floods, 7 an application filter.
                      Anything unparseable in the operator's free text is
                      dropped rather than shown.
                    items:
                      type: integer
                    example: [3, 4]
                  note:
                    type: string
                    example: ''
                  stated:
                    type: boolean
                    description: |
                      False when provider, layers and note are all empty. The
                      flag exists so a client renders a sentence instead of an
                      empty box.
                    example: true
                  filter:
                    nullable: true
                    description: |
                      What the node's own packet filter has caught, asked of the
                      node on every read.

                      Three states and they are all different. `null` means the
                      node did not answer -- an older daemon, or one having a bad
                      minute -- and the operator's answer above is still worth
                      showing. `available: false` means the node answered that
                      it has no filter installed. Anything else is a tally.

                      Packets, not attacks. The filter drops per source address;
                      it does not classify, time or name anything. `applied_at`
                      is half the measurement: reinstalling the rules resets
                      every counter to zero.
                    type: object
                    required: [available, dropped]
                    properties:
                      available:
                        type: boolean
                        example: true
                      applied_at:
                        type: string
                        example: '2026-08-26T08:55:00Z'
                      dropped:
                        type: integer
                        description: Every rule added up.
                        example: 44
                      rules:
                        type: array
                        items:
                          type: object
                          properties:
                            rule:
                              type: string
                              enum: [invalid, tcp_rate, tcp_concurrent, udp_rate]
                              example: tcp_concurrent
                            packets:
                              type: integer
                              example: 29
                            bytes:
                              type: integer
                              example: 1508
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/ssh-keys:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances, files]
      operationId: listInstanceSSHKeys
      summary: The public keys that open this instance's SFTP
      description: |
        Owner-level, like the SFTP password: a key opens the whole directory,
        which is a disposition rather than an operation.

        **Keys authenticate on their own.** They do not need the SFTP password
        to be set, and clearing that password does not disable them — exactly as
        `authorized_keys` works next to `PasswordAuthentication` on any other
        host.

        The key itself is never returned. Four hundred characters of base64
        tells a reader nothing; the fingerprint is what a person recognises,
        because it is the same `SHA256:…` their own client prints.
      responses:
        '200':
          description: Every key on the instance, oldest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/InstanceSSHKey'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [instances, files]
      operationId: addInstanceSSHKey
      summary: File a public key
      description: |
        Takes whatever a clipboard produces: an `authorized_keys` line, with or
        without a comment, with or without options in front, with or without a
        trailing newline. What is stored is the normalised type and base64, so
        the check at login is a byte comparison and the same key pasted twice
        under two spellings is one key.

        Ten keys per instance. The login path reads them all on every connect,
        so the column is bounded on purpose.

        Three refusals worth telling apart: a pasted **private** key answers
        `ssh_key_private`, because the right next step there is not "fix the
        paste" but "stop using that key"; DSA at any size and RSA under 2048
        bits answer `ssh_key_weak`; the same key twice answers `conflict`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [public_key]
              properties:
                name:
                  type: string
                  description: What the customer calls it. Recognition only.
                  example: Laptop
                public_key:
                  type: string
                  example: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... laptop@home
      responses:
        '201':
          description: The key, as it is now stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstanceSSHKey'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            The same key is already filed against this instance (`conflict`), or
            the instance already holds ten (`limit_ssh_keys_reached`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: |
            `ssh_key_private` for a pasted private key, `ssh_key_weak` for DSA
            or RSA under 2048 bits, `validation_failed` for anything that is not
            a public key at all.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/ssh-keys/{keyId}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
      - name: keyId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      tags: [instances, files]
      operationId: deleteInstanceSSHKey
      summary: Remove a public key
      description: |
        Takes effect on the next connect, like every other revoke here: a
        session already open is not torn down mid-transfer.
      responses:
        '204':
          description: Gone.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/usage:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: getInstanceUsage
      summary: The live usage sample and the ceilings it is measured against
      description: |
        Pairs the newest sample the node reported with the limits it is measured
        against, so a dashboard draws used/limit bars from one request instead of
        joining a usage call against a detail call that may already have moved
        on.

        `ram_limit_mb` is the **effective** limit: an active boost raises what the
        daemon actually enforces, and a bar drawn against the unboosted number
        would read over 100% for a user who just paid to avoid exactly that.

        Ownership is proved by the same owner-scoped lookup the detail endpoint
        uses -- the usage store is keyed by instance id alone and would happily
        answer for somebody else's server.
      responses:
        '200':
          description: Live sample plus limits.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstanceUsage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/usage/history:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: getInstanceUsageHistory
      summary: Aggregated usage buckets for a chart
      description: |
        Bucketed history, never per-sample rows: 5-minute buckets over 24h,
        hourly buckets over 7d. Live values never touch the database at all,
        which is what keeps the SQLite file small enough to snapshot in a second.

        Retention is enforced by the pruner: 5-minute buckets are dropped after
        30 hours, hourly ones after `USAGE_RETENTION` (8 days by default), so a
        `7d` window on a young instance simply returns fewer points.

        `window` is passed through unvalidated on purpose -- anything that is not
        `7d` is treated as `24h`, so a garbage value yields the default chart
        rather than an error the UI would have to render instead of data.
      parameters:
        - name: window
          in: query
          required: false
          description: '`24h` (5-minute buckets) or `7d` (hourly). Anything else means `24h`.'
          schema:
            type: string
            enum: [24h, 7d]
            default: 24h
          example: 7d
      responses:
        '200':
          description: Buckets, oldest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/UsageBucket'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/ports:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: listInstancePorts
      summary: Ports allocated to an instance
      description: |
        The primary port comes first. It is the instance's address and cannot be
        released; every other entry counts against the plan's `extra_ports`.
      responses:
        '200':
          description: The instance's port allocations, primary first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/InstancePort'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [instances]
      operationId: addInstancePort
      summary: Allocate one more port
      description: |
        Allocates the next free port in the node's range and pushes the complete
        new set to the daemon. Refused with `limit_ports_reached` once the plan's
        `extra_ports` allowance is used, and with `instance_suspended` while the
        instance is suspended.

        The caller does not choose the number: a user-picked port could collide
        with another instance on the same node, so only the label is theirs. If
        the push to the node fails the allocation is rolled back rather than left
        as a port the dashboard shows and nothing listens on.

        Requires a verified email address.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  maxLength: 64
                  description: What the port is for, shown next to it in the dashboard.
                  example: Dynmap
      responses:
        '201':
          description: The new allocation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstancePort'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503':
          description: No node could take the allocation (`node_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /instances/{id}/ports/{portId}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
      - name: portId
        in: path
        required: true
        description: Allocation id, not the port number.
        schema:
          type: string
        example: c04b7e19-6a3f-4d52-91c8-2e70b5d8a63f
    delete:
      tags: [instances]
      operationId: deleteInstancePort
      summary: Release an extra port
      description: |
        Frees the allowance again. The primary port is refused with `forbidden`
        and `details.reason = "primary_port"`: dropping it would leave a running
        server unreachable with no way to get the same address back.
      responses:
        '204':
          description: Released.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/env:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [instances]
      operationId: listInstanceEnv
      summary: Environment variables of an instance
      description: |
        Secret values are **never** returned: a row flagged `secret` comes back
        with `secret: true` and no `value` at all, so the UI shows a masked
        placeholder and can only overwrite it. That is also why there is no
        "reveal" endpoint anywhere in this API -- the plaintext leaves the
        database only on its way to the daemon.
      responses:
        '200':
          description: The instance's variables.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/EnvEntry'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/env/{key}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
      - name: key
        in: path
        required: true
        description: Variable name.
        schema:
          type: string
        example: DISCORD_TOKEN
    put:
      tags: [instances]
      operationId: setInstanceEnv
      summary: Create or overwrite one variable
      description: |
        `PUT`, because setting a variable is an upsert against a key the caller
        names: sending it twice must leave one row, not two. `secret: true`
        stores the value sealed with AES-GCM and hides it from every read.

        The full variable set is pushed to the daemon after the write, so the
        change is live on the next start without a manual sync step.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EnvValueRequest'
      responses:
        '204':
          description: Variable written and pushed to the node.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [instances]
      operationId: deleteInstanceEnv
      summary: Remove one variable
      description: |
        Deletes the row and pushes the remaining set to the daemon. Owner-scoped;
        an unknown key is a no-op rather than an error, so a retried delete does
        not fail.
      responses:
        '204':
          description: Variable removed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- files ----------------------------------------------------------------

  /instances/{id}/files:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [files]
      operationId: listInstanceFiles
      summary: List a directory inside the instance
      description: |
        Proxied to the instance's node after the owner-scoped lookup. Paths are
        resolved against the instance's data directory and cleaned before they
        leave the API, so `../` cannot walk out of it; the daemon refuses an
        escaping path a second time. An absent `path` lists the root.
      parameters:
        - $ref: '#/components/parameters/FilePath'
      responses:
        '200':
          description: Directory entries, directories first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/FileEntry'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [files]
      operationId: deleteInstanceFile
      summary: Delete a file or directory
      description: |
        Removes the named path inside the instance's data directory,
        recursively for a directory. Owner-scoped and path-cleaned like every
        other file operation. There is no trash: the delete is immediate on the
        node.
      parameters:
        - $ref: '#/components/parameters/FilePathRequired'
      responses:
        '204':
          description: Deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/files/contents:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [files]
      operationId: readInstanceFile
      summary: Read a file as raw text
      description: |
        Sent as `text/plain` rather than wrapped in JSON: the editor wants the
        bytes exactly as they are on disk, and a JSON round-trip would re-encode
        every config file's line endings and escapes.

        Reads are capped by the same `LIMIT_FILE_EDIT_BYTES` ceiling that governs
        writes; a larger file answers `limit_file_size_exceeded` rather than
        streaming a gigabyte into a browser tab.
      parameters:
        - $ref: '#/components/parameters/FilePathRequired'
      responses:
        '200':
          description: The file's bytes.
          content:
            text/plain:
              schema:
                type: string
              example: |
                server-port=25565
                motd=CitexNode Testserver
                max-players=20
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [files]
      operationId: writeInstanceFile
      summary: Write a file
      description: |
        Creates or replaces the file at `path`. The size ceiling
        (`LIMIT_FILE_EDIT_BYTES`, 1 MiB by default) is checked in the handler as a
        cheap early exit so an oversized editor buffer is refused before it
        travels to the node, and again in the service, which is the authority --
        both answer `payload_too_large` with the limit in `details`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WriteFileRequest'
      responses:
        '204':
          description: Written.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/files/upload:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [files]
      operationId: uploadInstanceFile
      summary: Upload a file, streamed
      description: |
        The request body **is** the file: raw bytes, no multipart wrapper, and
        the target is the `path` query parameter. Streamed end to end, so a
        200 MB world never becomes 200 MB of API memory and never becomes
        200 MB of node memory either.

        The ceiling is `LIMIT_INSTANCE_UPLOAD_BYTES` (256 MiB by default), which
        is deliberately not the text editor's much smaller limit: the two
        answer different questions -- "what should a browser open as text" and
        "what may land on a node". Over it answers `payload_too_large` with the
        limit in `details`.
      parameters:
        - $ref: '#/components/parameters/FilePathRequired'
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: Written, with the number of bytes that landed.
          content:
            application/json:
              schema:
                type: object
                required: [size]
                properties:
                  size:
                    type: integer
                    format: int64
                    example: 48211934
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503':
          description: The node is unreachable (`node_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /instances/{id}/files/download:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [files]
      operationId: downloadInstanceFile
      summary: Download a file, streamed
      description: |
        No size ceiling and no type check on the way out: a download is what a
        user reaches for exactly when a file is too big or too binary for the
        editor. Sent as an attachment with the file's own name.
      parameters:
        - $ref: '#/components/parameters/FilePathRequired'
      responses:
        '200':
          description: The file's bytes.
          headers:
            Content-Disposition:
              description: '`attachment; filename="..."` with the file''s own name.'
              schema:
                type: string
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503':
          description: The node is unreachable (`node_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /instances/{id}/files/extract:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [files]
      operationId: extractInstanceArchive
      summary: Unpack an archive in place
      description: |
        Unpacks the archive at `path` into the directory it sits in. Accepted:
        `.zip`, `.tar.gz`, `.tgz` and `.tar`. A Minecraft world exported from
        anything that is not Windows arrives as a tarball, and for a while this
        endpoint took the upload and then refused to open it.

        Entries are resolved inside the instance's data directory, so an archive
        carrying `../../etc/passwd` unpacks nowhere -- the classic way an upload
        becomes a node compromise. The check refuses a leading separator in both
        slash directions rather than relying on the platform's idea of an
        absolute path: the node runs Linux, and a guard that behaves differently
        elsewhere is a guard that passes the wrong test.

        From a tar, only regular files and directories are written. Symlinks,
        devices, fifos and hard links are dropped rather than recreated: a link
        pointing out of the mount is the one thing an archive must not be able
        to put on a node.

        Every way unpacking can fail answers one code, `archive_invalid`: a
        truncated archive, a jar renamed to `.zip`, a path that is a directory.
        Telling them apart helps nobody who is looking at their own file.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DirectoryRequest'
      responses:
        '200':
          description: Unpacked, with the number of entries written.
          content:
            application/json:
              schema:
                type: object
                required: [files]
                properties:
                  files:
                    type: integer
                    example: 214
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The archive could not be unpacked (`archive_invalid`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503':
          description: The node is unreachable (`node_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /instances/{id}/files/rename:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [files]
      operationId: renameInstanceFile
      summary: Rename or move a file
      description: |
        Both `from` and `to` are cleaned and resolved inside the instance's data
        directory, so a rename cannot be used to write outside it -- the classic
        way a file manager becomes a node compromise. Moving across directories
        is the same call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RenameFileRequest'
      responses:
        '204':
          description: Renamed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/files/directory:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [files]
      operationId: createInstanceDirectory
      summary: Create a directory
      description: |
        Creates the directory (and its missing parents) inside the instance's
        data directory. Owner-scoped and path-cleaned like every other file
        operation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DirectoryRequest'
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DirectoryCreated'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- domains ---------------------------------------------------------------

  /domains/pricing:
    get:
      tags: [domains]
      operationId: domainPricing
      summary: What each extension costs
      security: []
      description: |
        Unauthenticated, for the same reason `/plans` is: a price somebody has
        to sign up to see is a price they compare somewhere else.

        **Three prices per extension, and the renewal one is not a footnote.**
        Registries subsidise the first year and registrars pass that on, so a
        `.xyz` that registers for 9.99 renews for 24.99. Quoting only the first
        makes the second year a support ticket.

        Everything is in credits, where 100 credits is 1.00 EUR and one credit
        is one cent. `min_period` and `max_period` are the registrar's own
        limits in years -- `.de` sells one year and refuses two.
      responses:
        '200':
          description: Every extension on offer, cheapest registration first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/DomainPrice'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /domains/search:
    get:
      tags: [domains]
      operationId: searchDomains
      summary: Is this name free, and what does it cost
      security: []
      description: |
        One typed word checked against every extension on offer, in a single
        call to the registrar. `meinserver.de` checks that extension first and
        the rest after it; an extension the platform does not sell answers
        `not_found` rather than quietly checking twenty others.

        Unauthenticated, like the price list: a domain search behind a sign-in
        is a search people do somewhere else. Guarded instead by a tight bucket
        of **10 per minute per address** -- one search is one registrar lookup
        per extension, so ten searches is two hundred lookups.

        `available` folds the two rules a client should not have to know: the
        name is free **and** it is not a premium name. A premium name is priced
        individually by its registry -- four figures happens -- so the table's
        price does not apply to it and it is never offered.
      parameters:
        - name: q
          in: query
          required: true
          description: What somebody typed. A bare label, or a full name.
          schema:
            type: string
            example: meinserver
      responses:
        '200':
          description: One row per extension, priced.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/DomainOffer'
        '404':
          description: The extension in the query is not one the platform sells.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '502':
          description: |
            The registrar refused the lookup (`registrar_refused`). Its own code
            and description ride along in `details`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: |
            No registrar is configured, or it rejected the platform's own
            credentials (`domains_unavailable`). Nothing a customer can fix.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /domains:
    get:
      tags: [domains]
      operationId: listDomains
      summary: The domains this account holds
      description: |
        This platform's own table, not the registrar's. Status and expiry are
        copied here when a registration is made or refreshed; asking Openprovider
        on every page load would put a third party's response time in front of a
        list that changes a few times a year.

        A row with `demo: true` is a walkthrough row and not a registration. It
        has nothing behind it at the registrar, and every write below refuses it
        with `demo_resource`.
      responses:
        '200':
          description: Every domain on the account, by name.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Domain'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /domains/{id}:
    get:
      tags: [domains]
      operationId: getDomain
      summary: One domain
      description: |
        Scoped to the owner by the query itself, so somebody else's id answers
        `not_found` rather than `forbidden` -- the existence of a stranger's
        domain is not this account's business either.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The domain.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /domains/{id}/nameservers:
    put:
      tags: [domains]
      operationId: setDomainNameservers
      summary: Repoint a domain
      description: |
        The whole set, never a diff: every registrar replaces nameservers as a
        list, and a request that could add one would have to describe removing
        one too.

        Between two and four hostnames. One nameserver is a single point of
        failure that several registries refuse outright; four is the registrar's
        own ceiling.

        The table is written only after the registrar accepted. A copy that runs
        ahead of the registrar is worse than no copy.

        **A demo domain is refused** with `demo_resource`, before anything is
        written and before the registrar is called.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nameservers]
              properties:
                nameservers:
                  type: array
                  minItems: 2
                  maxItems: 4
                  items:
                    type: string
                  example: [ns1.citexnode.de, ns2.citexnode.de]
      responses:
        '200':
          description: The domain, with its new nameservers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            A demo row (`demo_resource`). There is no registration behind it, so
            there is nothing to repoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '502':
          description: |
            The registrar refused (`registrar_refused`). Its own code and
            description ride along in `details`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: No registrar is configured (`domains_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /domains/order:
    post:
      tags: [domains]
      operationId: orderDomain
      summary: Register a domain against the balance
      description: |
        The real purchase (2026-09-05). The sequence: the name is checked at the
        registrar once more (`domain_taken` if somebody was faster or it is a
        premium name), the balance is debited (`domain_order` in the ledger, with
        an invoice), the zone is created on ns1/ns2, then the registration runs
        at Openprovider. Any failure after the debit refunds (`domain_refund`)
        before the error leaves, and removes the zone again.

        `expected_price_cents` must equal the table's register price times
        `period` years; a table edit in between answers 409
        `domain_price_changed` with the current number.

        The registrar wants a phone number for every registrant. The first order
        of an account sends `phone` and it is kept; later orders may leave it
        out. Name, address and country come from the onboarding data; a missing
        piece answers `validation_failed` with the field named.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, expected_price_cents]
              properties:
                name: { type: string, example: meinserver.de }
                period:
                  type: integer
                  description: Years. Omitted or 0 is the extension's minimum.
                expected_price_cents: { type: integer }
                phone: { type: string, example: "+49 152 26825404" }
      responses:
        '200':
          description: The registration, stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { description: "Balance too low (`insufficient_credits`)." }
        '404': { description: "No such extension on offer (`not_found`)." }
        '409': { description: "Taken or premium (`domain_taken`), or the price moved (`domain_price_changed`)." }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '502': { description: "The registrar refused (`registrar_refused`); money refunded." }
        '503': { description: "No registrar configured (`domains_unavailable`), or the nameserver did not answer (`dns_unavailable`)." }

  /domains/{id}/renew:
    post:
      tags: [domains]
      operationId: renewDomain
      summary: Buy more years against the balance
      description: |
        `domain_renew` in the ledger with an invoice, then the registrar's renew;
        a refusal refunds. The expiry moves to what the registrar answers.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                period: { type: integer, description: Years, default 1. }
      responses:
        '200':
          description: The domain with its new expiry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { description: "Balance too low (`insufficient_credits`)." }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: "A demo row (`demo_resource`)." }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '502': { description: "The registrar refused (`registrar_refused`); money refunded." }
        '503': { description: "No registrar configured (`domains_unavailable`)." }

  /domains/{id}/settings:
    put:
      tags: [domains]
      operationId: setDomainSettings
      summary: The switches
      description: |
        Auto-renew, WHOIS privacy and the transfer lock live at the registrar
        and are written there first, copied only after it accepted. The two
        notify flags are the platform's own and never touch the registrar, so
        they work on a demo row and without a registrar.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DomainSettings'
      responses:
        '200':
          description: The domain as it is now.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: "A registrar-side switch on a demo row (`demo_resource`)." }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '502': { description: "The registrar refused (`registrar_refused`)." }
        '503': { description: "No registrar configured (`domains_unavailable`)." }

  /domains/{id}/dnssec:
    get:
      tags: [domains]
      operationId: getDomainDNSSEC
      summary: Whether the zone is signed, and its DS records
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The signing state, read from ns1.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DNSSECState'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503': { description: "The nameserver did not answer (`dns_unavailable`)." }
    put:
      tags: [domains]
      operationId: setDomainDNSSEC
      summary: Sign or unsign the zone
      description: |
        On: ns1 mints its default key (one CSK, ECDSAP256SHA256), the zone is
        rectified and ns2 notified, and the keys are published at the registry
        when a registrar is configured. Signing without a registrar is allowed
        and answers `registrar_synced: false` -- the zone is ready, the chain of
        trust waits. Off: keys removed on ns1 and withdrawn at the registry.

        **A demo domain is refused** with `demo_resource`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
      responses:
        '200':
          description: The signing state after the change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DNSSECState'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: "A demo row (`demo_resource`)." }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503': { description: "No nameserver configured or it did not answer (`dns_unavailable`)." }

  /domains/{id}/forwarding:
    get:
      tags: [domains]
      operationId: getDomainForwarding
      summary: The HTTP redirect
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The redirect and where it is served from.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Forwarding'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    put:
      tags: [domains]
      operationId: setDomainForwarding
      summary: Point the domain at a URL
      description: |
        Stores the target and writes the apex and `www` A records to the
        forwarding host (node3), whose nginx proxies every unknown host to
        `/forwarding/go` here. HTTP only: the redirect host has no certificate
        for a customer's name, so `https://kunde.de` does not redirect -- the
        area says so. An empty target switches forwarding off and removes the
        two A records; every other record is left alone. A target on the domain
        itself is refused (`loop`).

        **A demo domain is refused** with `demo_resource`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target]
              properties:
                target: { type: string, description: Absolute http(s) URL, or empty to switch off. }
                code: { type: integer, enum: [301, 302], default: 301 }
      responses:
        '200':
          description: The redirect after the change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Forwarding'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: "A demo row (`demo_resource`)." }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503': { description: "No forwarding host or nameserver configured (`dns_unavailable`)." }

  /domains/{id}/activity:
    get:
      tags: [domains]
      operationId: domainActivity
      summary: What was changed on this domain
      description: The audit rows keyed to the domain, in the shape the instance log uses.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, default: 25, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: One page of entries, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [items, total]
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/ActivityEntry'
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /forwarding/go:
    get:
      tags: [domains]
      operationId: forwardingGo
      summary: The redirect a forwarded domain answers
      description: |
        Not for API clients. The forwarding host's nginx proxies every request
        for a host it has no site for to this path with the browser's `Host` in
        `X-Forwarded-Host`; the answer is the customer's 301 or 302, or 404 for
        a host nobody forwards. `www.` is folded onto the apex.
      parameters:
        - name: X-Forwarded-Host
          in: header
          schema: { type: string }
      responses:
        '301': { description: Permanent redirect to the customer's target. }
        '302': { description: Temporary redirect to the customer's target. }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /domains/{id}/dns:
    get:
      tags: [domains]
      operationId: getDomainDNS
      summary: The zone on the platform's own nameservers
      description: |
        Since 2026-09-05 CitexNode runs ns1.citexnode.de and ns2.citexnode.de
        (PowerDNS; ns1 is written, ns2 follows by NOTIFY/AXFR). This is the
        zone as ns1 holds it, without the two things the platform manages: the
        SOA and the NS records at the apex.

        A domain whose zone was never created -- a demo row, a registration
        that still points elsewhere, or no nameserver configured -- answers
        `exists: false` and an empty list rather than an error. The first
        `PUT` creates the zone.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The zone.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DNSZone'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503':
          description: The nameserver did not answer (`dns_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    put:
      tags: [domains]
      operationId: setDomainDNS
      summary: Replace the customer's records
      description: |
        The whole set, never a diff, for the same reason the nameservers are:
        the platform works out which name/type groups to replace and which to
        delete, and the SOA and the apex NS are touched by neither.

        Every record is checked before anything is sent: type from the allowed
        list (A, AAAA, CNAME, ALIAS, MX, SRV, TXT, CAA, NS, PTR, TLSA), NS not
        at the apex, TTL between 60 and 86400 (0 means 3600), content in the
        shape its type wants -- `10 mail.example.de` for MX, `0 issue
        letsencrypt.org` for CAA. TXT and CAA values are quoted by the platform;
        hostnames get their trailing dot. A CNAME stands alone at its name.
        At most 200 records.

        The zone is created on the first write, with the platform's NS records.
        After the change ns1 notifies ns2, so both answer within seconds.

        **A demo domain is refused** with `demo_resource`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [records]
              properties:
                records:
                  type: array
                  maxItems: 200
                  items:
                    $ref: '#/components/schemas/DNSRecord'
      responses:
        '200':
          description: The zone as it is now.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DNSZone'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: A demo row (`demo_resource`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503':
          description: |
            No nameserver is configured, or it did not answer
            (`dns_unavailable`). Nothing was changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /domains/{id}/authcode:
    get:
      tags: [domains]
      operationId: getDomainAuthCode
      summary: The transfer code
      description: |
        Read from the registrar on every call and never stored. A code sitting
        in this database is a code in a backup, and it is the one secret whose
        whole purpose is to let somebody take the domain elsewhere.

        A customer is entitled to it, which is why it is one request and not a
        support ticket.

        **A demo domain is refused** with `demo_resource`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The code.
          content:
            application/json:
              schema:
                type: object
                required: [auth_code]
                properties:
                  auth_code:
                    type: string
                    example: aB3-xY9-qW2
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: A demo row (`demo_resource`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '502':
          description: The registrar refused (`registrar_refused`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: No registrar is configured (`domains_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /permissions/resources:
    get:
      tags: [kvm, domains]
      operationId: resourcePermissions
      summary: The rights a customer can hand out on one server or one domain
      description: |
        Five catalogues -- game server, bot, KVM, domain, and the account's own
        team and money -- grouped the way a client should show them: a column of
        eighty checkboxes is a column nobody reads to the end, and two clients
        grouping the same keys differently is two answers to "what may this
        person do".

        Rights are per resource. The same person can be Owner on one game
        server, Viewer on the next and Developer on a bot, because those are
        different jobs on different machines.

        Each product also carries six role presets. A preset is a starting point
        that is then narrowed, not a rung on a ladder: the useful cases are not
        nested, and any ordered ladder gets either the developer who must not
        see the invoice or the bookkeeper who must not restart anything wrong.

        Only keys travel. Every label is translated in the client, because these
        lists are read by customers rather than operators.

        These are **not** the operator permissions under `/admin`. Those let
        staff act on other people's accounts; these let a customer share one
        thing they own. `instances.danger` deletes anybody's server,
        `server.delete` deletes this one.

        **Nothing enforces them yet.** There is no hypervisor, so there is no
        machine to share, and per-resource membership does not exist for either
        product. The catalogue is published first on purpose: these keys end up
        in stored rows and in an audit trail, and a second vocabulary invented
        later is a migration nobody wants to write.
      responses:
        '200':
          description: |
            Every catalogue, keyed by product, in the order a client should
            render it. `account` carries the account's own team and its money
            and has no roles: it is not a resource somebody is a Developer on.
          content:
            application/json:
              schema:
                type: object
                required: [levels, resources]
                properties:
                  levels:
                    type: array
                    items:
                      type: string
                    example: [read, write, dangerous]
                  resources:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        groups:
                          type: array
                          items:
                            $ref: '#/components/schemas/PermissionGroup'
                        roles:
                          type: array
                          items:
                            $ref: '#/components/schemas/RolePreset'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /kvm:
    get:
      tags: [kvm]
      operationId: listKVMMachines
      summary: The virtual servers this account holds
      description: |
        Read-only, and that is the design rather than a first cut. There is no
        hypervisor behind this table, so nothing on this platform can start,
        stop or reinstall a machine, and an endpoint that accepted the request
        would be a control whose only honest answer is an error.

        Ordering is absent for a different reason: credits are not purchasable,
        so nothing can be bought. The public KVM page says so too.

        `price_credits` is the monthly price as it stood when the machine was
        ordered, stored rather than recomputed -- what a customer pays must not
        move because somebody edited a factor in a price table.
      responses:
        '200':
          description: Every machine on the account, oldest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/KVMMachine'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /kvm/{id}:
    get:
      tags: [kvm]
      operationId: getKVMMachine
      summary: One virtual server
      description: |
        Scoped to the owner by the query, like a domain: a stranger's id answers
        `not_found`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The machine.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KVMMachine'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/domains/pricing:
    get:
      tags: [admin, domains]
      operationId: adminDomainPricing
      summary: The price list with the registrar's own prices beside it
      description: |
        Requires `economy.read` -- a domain price is money, and it sits under the
        same permission as the credit economy rather than growing a third one.

        **This is the view that catches the mistake the table exists for.** The
        first price list written by hand had 19 of its 20 rows below cost,
        because it was derived from advertised first-year prices; nothing in the
        platform could report it, because nothing compared the two numbers.

        Loss-making rows sort first. A registrar that does not answer is not an
        error: the platform's own prices still show and the cost columns say
        `known: false` rather than a zero that reads like a fact.
      responses:
        '200':
          description: Every extension, sold and unsold, with its margin.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/DomainMargin'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/domains/pricing/rule:
    get:
      tags: [admin, domains]
      operationId: adminDomainPriceRule
      summary: The stored price formula and its last run
      description: |
        What the last non-dry sync stored: markup, fixed part, whether the
        scheduler re-runs it daily, when it last ran and with what counts.
        Requires `economy.read`.
      responses:
        '200':
          description: The rule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DomainPriceRule'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/domains/pricing/sync:
    post:
      tags: [admin, domains]
      operationId: adminSyncDomainPricing
      summary: Price every extension the registrar sells
      description: |
        Reads the registrar's whole catalogue (about two thousand extensions)
        and writes a row for every sellable one that has none yet: reseller
        price plus `percent_markup`, plus `add_cents`, rounded up to the next
        ,99 -- never at or below cost. Rows that already exist are left alone,
        so a hand-set price always wins and the sync can be repeated whenever
        the registrar adds an extension. `dry_run` reports without writing.

        Requires `economy.write`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                percent_markup: { type: integer, minimum: 0, maximum: 500, example: 25 }
                add_cents: { type: integer, minimum: 0, maximum: 100000, example: 100 }
                dry_run: { type: boolean, default: false }
                overwrite:
                  type: boolean
                  default: false
                  description: Apply the formula to existing rows too (their active flag is kept).
                autosync:
                  type: boolean
                  default: false
                  description: |
                    Store the rule and let the scheduler re-run it every day with
                    overwrite, so registrar price moves and promotions propagate.
      responses:
        '200':
          description: What was (or would be) written.
          content:
            application/json:
              schema:
                type: object
                required: [total, added, kept, updated, skipped, promos, sample]
                properties:
                  total: { type: integer, description: Sellable extensions with a price. }
                  added: { type: integer }
                  kept: { type: integer, description: Rows that existed and were left alone. }
                  updated: { type: integer, description: Rows re-priced by the formula (overwrite only). }
                  promos: { type: integer, description: Rows currently carrying a registrar promotion. }
                  skipped: { type: integer, description: Extensions without a usable price. }
                  sample:
                    type: array
                    items:
                      $ref: '#/components/schemas/DomainPrice'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '502': { description: "The registrar refused (`registrar_refused`)." }
        '503': { description: "No registrar configured (`domains_unavailable`)." }

  /admin/domains/pricing/{tld}:
    parameters:
      - name: tld
        in: path
        required: true
        description: The extension, without a leading dot.
        schema:
          type: string
          example: de
    put:
      tags: [admin, domains]
      operationId: setDomainPrice
      summary: Set what an extension costs a customer
      description: |
        Requires `economy.write`. Creates the row when the extension is new.

        **A price at or below what the registrar charges is refused**, with
        `domain_price_below_cost` and the registrar's own figure in `details`.
        Refused rather than warned about: the mistake is invisible until the
        invoices arrive, and it has already happened once on nineteen rows at a
        time. An operator who genuinely wants a loss leader can deactivate the
        extension first and price it afterwards, which is a deliberate two-step
        rather than a slip.

        Credits, never euros. The two are the same number times a hundred, and a
        field that could carry either will one day carry the wrong one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DomainPriceRequest'
      responses:
        '200':
          description: The stored row.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DomainPrice'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: The registrar does not offer this extension.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: |
            The price is at or below the registrar's own
            (`domain_price_below_cost`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '502':
          description: The registrar refused the lookup (`registrar_refused`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # --- backups --------------------------------------------------------------

  /instances/{id}/backups:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [backups]
      operationId: listBackups
      summary: Backups of one instance
      description: |
        Every backup row for the instance, newest first, including the ones still
        `pending` or `running` and the ones that ended `failed` with a reason.
        Owner-scoped.
      responses:
        '200':
          description: The instance's backups.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Backup'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [backups]
      operationId: createBackup
      summary: Take a backup
      description: |
        Requires a verified address -- a backup consumes disk on the node, so it
        sits behind verification like the other endpoints that cost something.

        Bounded by the plan's `backup_slots`: `free-game` grants 1 and
        `free-bot` grants 0, so a bot plan refuses every call here with
        `limit_backups_reached`. Delete an existing backup to free the slot.

        The row is returned in `pending` or `running`; the node finishes the
        archive afterwards and the status moves to `completed` with a size.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NameRequest'
      responses:
        '201':
          description: The backup row, still being written on the node.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Backup'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/backups/upload:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [backups]
      operationId: uploadBackup
      summary: File an archive from elsewhere as a backup
      description: |
        The other direction of `downloadBackup`, for a world that came from
        somewhere else: another host, a local single-player save, last year's
        server. The archive lands as an ordinary backup row and is restored with
        `restoreBackup` like any other, which is what keeps the unpacking on the
        one path that already refuses to run on a live instance and already
        refuses a path climbing out of the mount.

        **The request body is the archive itself** -- a gzipped tar, no
        multipart, `Content-Type: application/gzip`. It is streamed to the node
        rather than buffered, so the API never holds it in memory. The name is
        an optional `name` query parameter; without one the backup is named
        after the time it arrived.

        Requires a verified address and costs a backup slot, exactly like
        `createBackup`: `free-game` grants 1 and `free-bot` grants 0, so a bot
        plan refuses every call here with `limit_backups_reached`.

        The node writes to a temporary file and only files it as a backup once
        it reads back as a gzip stream carrying a tar with at least one entry.
        Anything else answers **422 `archive_invalid`** and nothing is stored --
        a file that turns out to be the wrong one on the day it is needed is
        worse than a refused upload. Over `LIMIT_INSTANCE_UPLOAD_BYTES` answers
        413.
      requestBody:
        required: true
        content:
          application/gzip:
            schema:
              type: string
              format: binary
      parameters:
        - name: name
          in: query
          required: false
          description: What to call the backup. Defaults to the time it arrived.
          schema:
            type: string
            maxLength: 128
      responses:
        '201':
          description: The backup row, complete, with the archive already on the node.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Backup'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422':
          description: |
            The body is not a readable gzipped tar (`archive_invalid`), or a
            field is malformed (`validation_failed`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/backups/{backupId}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
      - $ref: '#/components/parameters/BackupId'
    delete:
      tags: [backups]
      operationId: deleteBackup
      summary: Delete a backup
      description: |
        Removes the archive from the node and the row from the database, freeing
        the plan's backup slot. Owner-scoped through the instance; a backup id
        belonging to another instance answers `not_found`.
      responses:
        '204':
          description: Backup deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/backups/{backupId}/restore:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
      - $ref: '#/components/parameters/BackupId'
    post:
      tags: [backups]
      operationId: restoreBackup
      summary: Restore a backup over the running data directory
      description: |
        Requires a verified address. Destructive: the archive replaces the
        instance's current data directory. The instance is stopped and left
        offline for the user to start again.

        Answers **202** because the node does the unpacking after the call
        returns. A backup that is not `completed` is refused with `conflict`.
      responses:
        '202':
          description: Accepted. The node unpacks the archive.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Accepted'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/backups/{backupId}/download:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
      - $ref: '#/components/parameters/BackupId'
    get:
      tags: [backups]
      operationId: downloadBackup
      summary: Download a backup archive
      description: |
        Streams the gzipped tar straight from the node through the API, so the
        node itself never needs to be reachable from the internet. The filename
        is the backup's name with a `.tar.gz` suffix, sanitised before it becomes
        a header value. Owner-scoped through the instance.
      responses:
        '200':
          description: The archive.
          headers:
            Content-Disposition:
              description: Attachment, filename derived from the backup name.
              schema:
                type: string
          content:
            application/gzip:
              schema:
                type: string
                format: binary
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- databases ------------------------------------------------------------

  /instances/{id}/plugins:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [instances]
      operationId: listInstalledPlugins
      summary: The jars already in the folder
      description: |
        `plugins/` for a Paper-family or proxy runtime, `mods/` for a mod
        loader. An instance that has never started has neither yet; that is an
        empty list rather than a 404.
      responses:
        '200':
          description: Installed jars.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/InstalledPlugin'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [instances]
      operationId: installPlugin
      summary: Install a plugin from Modrinth
      description: |
        Resolves the project to the newest release for this instance's loader
        and streams the jar into the folder. Modrinth to the API to the node,
        with nothing buffered at either end -- there is no new node operation
        behind this and no daemon that can fetch an arbitrary URL.

        **Version choice.** The newest release that names the server's own
        Minecraft version wins. If there is none, the newest release for the
        loader is installed and `matches_server` comes back `false`. Refusing
        instead would mean a user on a Minecraft released last week can install
        nothing, which is wrong far more often than it is right -- a project's
        `game_versions` names what it was published for, not what it runs on.

        The SHA-1 Modrinth published is verified as the bytes go past, and a
        mismatch deletes what was written and answers
        `502 plugin_checksum_mismatch`. A corrupt jar left in the folder is one
        the server loads, throws on, and the user blames the plugin for.

        The plan's upload ceiling applies. Writes an `instance.plugin_install`
        audit row, which the owner reads in their activity feed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project]
              properties:
                project:
                  type: string
                  description: Modrinth project id or slug, from the search results.
                  example: essentialsx
      responses:
        '201':
          description: The release that was installed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginVersion'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: '`not_found`. Modrinth has no release of that project for this loader.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: '`plugins_unsupported` for a runtime that loads nothing (Vanilla, Bedrock), or `instance_suspended`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: '`limit_file_size_exceeded`. The jar is over the plan''s upload ceiling.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: '`plugins_unavailable` when Modrinth does not answer, `plugin_checksum_mismatch` when the file that arrived is not the file that was published.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/plugins/search:
    get:
      tags: [instances]
      operationId: searchPlugins
      summary: Search Modrinth for this instance's loader
      description: |
        Filtered by the loader the instance's runtime maps to and by project
        type, and deliberately **not** by the server's Minecraft version: on a
        freshly released Minecraft that filter empties the browser and reads as
        "there are no plugins".

        `supported: false` is the answer for Vanilla and Bedrock, which load
        nothing. The page then says so rather than offering a search box that
        can only disappoint.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: q
          in: query
          description: Free text. Empty returns the most popular projects for the loader.
          schema:
            type: string
      responses:
        '200':
          description: What Modrinth returned, plus what the caller needs to render it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginCatalogue'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: '`plugins_unavailable`. Modrinth did not answer; not our outage and not the user''s fault.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/modpacks:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [instances]
      operationId: installModpack
      summary: Install a Modrinth modpack
      description: |
        A pack is not a file to install but a manifest of a few hundred of them.
        This resolves the project to the newest `.mrpack` for the instance's
        loader, reads its `modrinth.index.json`, fetches every file the manifest
        names, and unpacks the lot on the node.

        **What comes back is 202, not 201.** Everything that can be refused is
        refused before this answers -- the runtime, the pack's Minecraft
        version, the manifest itself -- but fetching several hundred megabytes
        takes minutes, and no request is held open for that. Progress arrives on
        the instance's `events` channel as `modpack` frames carrying
        `{state, done, total, file}`, ending in `state: done` or `state: failed`
        with a `code`.

        **The server is only touched once the bytes are safely there.** The
        files are streamed into a single archive on the way to the node; only
        after that upload completes is the old `mods/` directory removed and the
        archive unpacked. A pack that fails half way leaves a working server.

        **`mods/` is replaced, not merged.** A pack laid over a previous one
        would leave jars nothing asked for, which is exactly the crash this
        exists to prevent. Worlds, and everything outside `mods/` and the pack's
        own overrides, are untouched.

        Client-only entries (`env.server: unsupported`) are skipped -- a server
        that loads one crashes on start. Every file is verified against the
        SHA-1 the manifest published, and every download must come from
        Modrinth's own CDN.

        The instance must not be running, for the same reason a reinstall
        requires it. The plan's upload ceiling applies to the assembled archive.
        Writes an `instance.modpack_install` audit row.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project]
              properties:
                project:
                  type: string
                  description: Modrinth project id or slug, from the search results.
                  example: fabulously-optimized
      responses:
        '202':
          description: The pack was accepted; the files follow on the event channel.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModpackInstall'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: '`not_found`. Modrinth has no release of that pack for this loader.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: |
            `modpack_unsupported` for a runtime that loads no mods (Paper, the
            proxies, Vanilla, Bedrock), `modpack_version_mismatch` when the pack
            was built for a different Minecraft than the server runs -- the
            details carry `pack_version` and `server_version` --
            `instance_running` while the server is up, or `instance_suspended`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: '`limit_file_size_exceeded`. The assembled pack is over the plan upload ceiling.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: |
            `modpack_invalid` for an archive that is not a usable pack: no
            manifest, a `formatVersion` this code does not know, or a path in it
            that points outside the server directory. Otherwise
            `validation_failed`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: '`plugins_unavailable` when Modrinth does not answer, `plugin_checksum_mismatch` when the pack that arrived is not the pack that was published.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/modpacks/search:
    get:
      tags: [instances]
      operationId: searchModpacks
      summary: Search Modrinth for packs this instance can run
      description: |
        The plugin browser one shelf over, filtered to `project_type: modpack`
        and to the instance's loader. Deliberately **not** filtered by the
        server's Minecraft version, for the same reason the plugin search is
        not: on a freshly released Minecraft that empties the browser and reads
        as "there are no packs". A pack for the wrong version is refused at
        install time, where the answer can name both versions.

        `supported: false` is the answer for every runtime that loads no mods --
        Paper and the two proxies take plugins, Vanilla and Bedrock take
        nothing. The page then says so instead of offering a search box that can
        only disappoint.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: q
          in: query
          description: Free text. Empty returns the most popular packs for the loader.
          schema:
            type: string
      responses:
        '200':
          description: What Modrinth returned, plus what the caller needs to render it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModpackCatalogue'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: '`plugins_unavailable`. Modrinth did not answer; not our outage and not the caller fault.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /instances/{id}/databases:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [databases]
      operationId: listDatabases
      summary: Managed databases of one instance
      description: |
        Returns the connection details **including the password**, decrypted on
        read: a database is useless without it, and the endpoint is owner-scoped
        so the credential never leaves the account that owns it. This is the only
        place in the API where a stored secret is served back in plaintext, and
        it is a deliberate exception -- environment secrets are not.
      responses:
        '200':
          description: Databases with credentials.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/DatabaseView'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [databases]
      operationId: createDatabase
      summary: Create a managed database
      description: |
        Requires a verified address. Bounded by the plan's `database_slots` (1 on
        both free plans); a second call answers `limit_databases_reached`.

        The `name` in the body is accepted and ignored: the service generates a
        collision-free schema name, because two users may well both want "main".
        The generated name, the user, the password, host and port come back in
        the response and are not retrievable any other way than
        `GET /instances/{id}/databases`.

        The schema, the user and the grant are created on the MySQL host before
        the row is written; if the host will not answer, the call fails with
        `databases_unavailable` and no row is left behind. A credential that
        connects to nothing is worse than no answer at all -- it is pasted into
        a plugin config and reported as a broken server.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NameRequest'
      responses:
        '201':
          description: The created database, with credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DatabaseView'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503':
          description: No MySQL host is configured, or it will not answer (`databases_unavailable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /instances/{id}/databases/{dbId}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
      - name: dbId
        in: path
        required: true
        description: Managed database id.
        schema:
          type: string
        example: 7c2f9b18-5e44-4a0d-9f31-6ab8c0d21e57
    delete:
      tags: [databases]
      operationId: deleteDatabase
      summary: Drop a managed database
      description: |
        Drops the schema and its user, then removes the row, freeing the plan's
        database slot. Irreversible -- the data is gone, and a backup of the
        instance does not contain it. Owner-scoped through the instance.

        If the MySQL host refuses the drop, the row stays and the call answers
        `databases_unavailable`. The row is the only record that the schema
        exists; removing it while the schema survives turns real disk into data
        nobody can see, reach or delete.

        Deleting the instance does the same thing for every database it owns, so
        a deleted server never leaves one behind.
      responses:
        '204':
          description: Database dropped.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- tickets --------------------------------------------------------------

  /tickets:
    get:
      tags: [tickets]
      operationId: listTickets
      summary: The caller's support threads
      description: |
        Scoped to the caller unconditionally: the ticket filter has no notion of
        who is asking, so the user id is pinned from the token here and never
        read from the query. The unscoped staff inbox is a different endpoint,
        `GET /admin/tickets`, behind `adminRequired`.

        An unknown `status` is rejected rather than filtered, because an empty
        page reads as "you have no tickets".
      parameters:
        - name: status
          in: query
          required: false
          description: Restrict to one status. Absent means all.
          schema:
            $ref: '#/components/schemas/TicketStatus'
          example: open
        - $ref: '#/components/parameters/Limit20'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: One page of the caller's tickets.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketPage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [tickets]
      operationId: createTicket
      summary: Open a support thread
      description: |
        Creates the ticket in `open` together with its first message in one
        transaction. Subject and body are trimmed and length-checked by the
        service (a body of only whitespace is a 422, not an empty thread).
        `priority` defaults to `normal` when omitted. An `instance_id` is
        verified to belong to the caller before it is attached, so a ticket
        cannot be used to point support at somebody else's server.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketCreateRequest'
      responses:
        '201':
          description: The created ticket.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Ticket'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /tickets/{id}:
    parameters:
      - $ref: '#/components/parameters/TicketId'
    get:
      tags: [tickets]
      operationId: getTicketThread
      summary: One thread with all its messages
      description: |
        The ticket plus every message, each with its author and attachments.

        The caller's role decides which lookup runs: a member gets the
        owner-scoped one and a foreign ticket is `not_found`; an admin gets the
        unscoped one and can read any thread through this same route. That
        decision lives in the service, which is why the staff UI needs no second
        set of endpoints.
      responses:
        '200':
          description: The thread.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketThread'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /tickets/{id}/reply:
    parameters:
      - $ref: '#/components/parameters/TicketId'
    post:
      tags: [tickets]
      operationId: replyToTicket
      summary: Post a message into a thread
      description: |
        Appends a message and moves the status: a member's reply sets
        `customer_reply`, a staff reply sets `answered`, and both stamp
        `last_reply_at` so the inbox sorts by what actually needs attention.
        `is_staff` is derived from the caller's role, never from the body.

        A closed ticket refuses with `ticket_closed` -- reopen it first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketReplyRequest'
      responses:
        '201':
          description: The created message.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketMessage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /tickets/{id}/close:
    parameters:
      - $ref: '#/components/parameters/TicketId'
    post:
      tags: [tickets]
      operationId: closeTicket
      summary: Close a thread
      description: |
        Sets `closed` and stamps `closed_at`. Either side may close: a member who
        solved it themselves, or staff finishing the case. Owner-scoped for
        members, unscoped for admins. Closing an already-closed ticket answers
        `ticket_closed`.
      responses:
        '204':
          description: Ticket closed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /tickets/{id}/reopen:
    parameters:
      - $ref: '#/components/parameters/TicketId'
    post:
      tags: [tickets]
      operationId: reopenTicket
      summary: Reopen a closed thread
      description: |
        Puts a closed ticket back into `customer_reply` and clears `closed_at`,
        so a follow-up question continues the existing thread instead of opening
        a second one that support has to correlate by hand. Owner-scoped for
        members, unscoped for admins.
      responses:
        '204':
          description: Ticket reopened.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /tickets/messages/{messageId}/attachments:
    parameters:
      - name: messageId
        in: path
        required: true
        description: Id of the message the file is attached to.
        schema:
          type: string
        example: 41b8e0d2-77c3-4c9a-9e15-3f6d0a2b8c44
    post:
      tags: [tickets]
      operationId: attachToTicketMessage
      summary: Attach a file to a message
      description: |
        Multipart upload under the field name `file`. The message must belong to
        a thread the caller owns.

        Two size checks, on purpose: the declared `Content-Length` of the part is
        rejected before the file is opened, so an oversized upload is refused
        without spooling it, and the bytes actually read are counted again during
        the copy -- a client may understate the size, and that second count is the
        authority. Both answer `payload_too_large` with `max_bytes` in `details`.

        The MIME type is checked against an allowlist (PNG, JPEG, WebP, GIF,
        plain text, log, JSON, gzip, zip); anything else is refused.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The file to attach.
      responses:
        '201':
          description: The stored attachment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketAttachment'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /tickets/attachments/{attachmentId}:
    parameters:
      - name: attachmentId
        in: path
        required: true
        description: Attachment id.
        schema:
          type: string
        example: 9a3c1f57-2b60-4d8e-b1a4-05c7e93f2d10
    get:
      tags: [tickets]
      operationId: downloadTicketAttachment
      summary: Download an attachment
      description: |
        Streams the stored file. Owner-scoped for members; admins may fetch any
        attachment, decided by the same role check the thread endpoints use.

        The response content type is re-derived from the allowlist rather than
        echoed blindly from the stored value: this is the one place where
        trusting a stored string would turn a bad row into the browser's
        rendering instruction. Anything unrecognised is served as
        `application/octet-stream`, i.e. as a download.

        A missing file behind an existing row is an operational fault, not a
        client error, so it answers 500 and lands in the error log.
      responses:
        '200':
          description: The attachment's bytes.
          headers:
            Content-Disposition:
              description: Attachment, filename sanitised from the stored name.
              schema:
                type: string
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- realtime -------------------------------------------------------------

  /ws/ticket:
    post:
      tags: [realtime]
      operationId: issueWebSocketTicket
      summary: Mint a short-lived ticket for a WebSocket connection
      description: |
        A browser cannot set an `Authorization` header on a WebSocket, so the
        access token buys a short-lived scoped ticket over normal HTTP and the
        ticket travels in the query string of the socket URL.

        The ticket's scope names **both** the channel and the instance
        (`console:<instance id>` / `events:<instance id>`), so a ticket that
        leaks through a proxy log or a browser history cannot be pointed at
        another instance or at the other channel of the same one.

        Issuing does **not** check ownership: the connect handlers do, against
        the id inside the ticket. A ticket for somebody else's instance is
        therefore mintable and useless, and the ownership check stays in one
        place instead of two that can drift apart. The TTL is about a minute,
        which is sufficient rather than load-bearing for exactly that reason.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WSTicketRequest'
      responses:
        '200':
          description: The ticket and its expiry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WSTicketResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /ws/instances/{id}/console:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [realtime]
      operationId: connectInstanceConsole
      summary: WebSocket - live console of one instance
      description: |
        **This is not a normal GET.** It is a WebSocket upgrade, and OpenAPI
        cannot describe a socket properly: what follows the handshake is a
        bidirectional frame stream with no request/response pairs, no status
        codes and no content types. The documentation below describes that
        stream in prose rather than pretending it is a JSON body.

        **Getting in.** Mint a ticket with `POST /ws/ticket` using
        `scope: "console"` and this instance's id, then connect to this path with
        `?ticket=<ticket>`. There is no `Authorization` header on this request.
        The scope must name this channel and this instance or the handshake is
        refused; ownership is re-checked against the user id **inside** the
        ticket, never the one in the URL. The `Origin` must be one the CORS
        configuration allows.

        **Frames from the server.** JSON objects shaped like
        `{"stream": "stdout" | "stderr" | "system", "line": "..."}` -- see the
        `ConsoleLine` schema. The server pings every 25s and drops a peer that
        has been silent for 70s.

        **Frames to the server.** For a game server (`kind: game`), a text frame
        is a command written to the container's stdin. For a bot the stream is
        log output only and writes are silently ignored -- a bot has no console
        to type into. Frames larger than 2048 bytes close the connection: a
        console frame is a command someone typed, not an upload.
      security: []
      parameters:
        - name: ticket
          in: query
          required: true
          description: 'A ticket from `POST /ws/ticket` with scope `console:<instance id>`.'
          schema:
            type: string
          example: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6ImNvbnNvbGU6YjNmMSJ9
      responses:
        '101':
          description: Switching protocols. The console stream starts.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/Internal' }

  /ws/instances/{id}/events:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [realtime]
      operationId: connectInstanceEvents
      summary: WebSocket - status and usage feed of one instance
      description: |
        **This is not a normal GET.** It is a WebSocket upgrade; as with the
        console, OpenAPI has no way to describe the frame stream that follows, so
        it is described here in prose instead of being faked as a response body.

        **Getting in.** Mint a ticket with `POST /ws/ticket` using
        `scope: "events"` and this instance's id, then connect with
        `?ticket=<ticket>`. The ticket's scope names both the channel and the
        instance, so an events ticket cannot open a console and a ticket for
        another instance cannot open this one. Ownership is re-checked on connect
        against the user id inside the ticket.

        **Frames from the server.** `Event` objects: `kind` says what happened
        (`usage`, `status`, `backup`, ...), `data` carries the payload -- a
        `Usage` sample for `usage`, the new lifecycle status for `status`. This
        is the feed that moves the dashboard's status dot and its usage bars, and
        it is why `POST /instances/{id}/power` can answer 202 without the client
        polling.

        **Frames to the server.** None are interpreted. The connection is
        read-only; the reader exists only to notice that the tab is gone and to
        answer pings.
      security: []
      parameters:
        - name: ticket
          in: query
          required: true
          description: 'A ticket from `POST /ws/ticket` with scope `events:<instance id>`.'
          schema:
            type: string
          example: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6ImV2ZW50czpiM2YxIn0
      responses:
        '101':
          description: Switching protocols. The event stream starts.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- internal -------------------------------------------------------------

  /discord/users/{discordId}:
    parameters:
      - name: discordId
        in: path
        required: true
        description: The Discord account's snowflake id.
        schema:
          type: string
    get:
      tags: [internal]
      operationId: botAccount
      summary: Who a Discord member is here
      description: |
        The Discord bot's own surface, guarded by `BOT_TOKEN` and never by a
        user session. The bot is not a user: it authenticates as itself and
        names a Discord id, and `oauth_identities` is the only thing that turns
        that id into an account.

        **`linked: false` is a normal answer, not a 404.** It is the most common
        one, and the bot prints a sentence with a next step rather than treating
        it as a failure.

        No email address in the answer. The bot writes into channels other
        people read, and an address that reaches a Discord message is one the
        account holder never agreed to publish.

        Not mounted at all when `BOT_TOKEN` is empty.
      responses:
        '200':
          description: The linked account, or that there is none.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BotAccount'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/Internal' }

  /discord/users/{discordId}/instances:
    parameters:
      - name: discordId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [internal]
      operationId: botInstances
      summary: A linked member's servers
      description: |
        Read-only, and deliberately so. Starting and stopping over Discord is
        worth having and is not here yet: this token lives in a bot process, and
        one that could stop instances would put every account on the platform
        behind a single string in a container's environment.
      responses:
        '200':
          description: The member's servers, trimmed to what fits an embed.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/BotInstance'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: '`bot_not_linked`. That Discord id belongs to no account.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /discord/users/{discordId}/instances/{id}/power:
    parameters:
      - name: discordId
        in: path
        required: true
        schema:
          type: string
      - name: id
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [internal]
      operationId: botPower
      summary: Start, stop or restart a linked member's own server
      description: |
        **Not a widening of what the bot token reaches.** The instance is
        resolved through the same access check the website uses, so a Discord id
        naming somebody else's server gets `not_found` — exactly what a stranger
        gets. The audit row names the account, not the bot.

        `kill` is deliberately absent. It is the panel's last resort — pulling
        the plug on a process mid-write — and the button for it belongs where
        the warning next to it fits.

        A suspended instance refuses everything but `stop`, and one that is
        still installing refuses everything: both rules come from the shared
        service, not from here.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [start, stop, restart]
      responses:
        '204':
          description: Accepted; the node was told. Status follows on the event channel.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: '`not_found`. No such server for this account.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: |
            `bot_not_linked`, `instance_suspended` (anything but `stop`), or
            `instance_busy` while it is installing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '503':
          description: '`node_unavailable`. The node did not answer.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /discord/users/{discordId}/tickets:
    parameters:
      - name: discordId
        in: path
        required: true
        schema:
          type: string
    post:
      tags: [internal]
      operationId: botOpenTicket
      summary: Open a real ticket from a Discord thread
      description: |
        **This is what keeps Discord from becoming a second inbox.** A thread in
        the server is where a conversation starts; the ticket it opens is the
        same row, in the same list, with the same validation as one opened on
        the website, and staff answer it in one place.

        No priority in the body. That is a judgement staff make, and an asker
        who can set it sets `high` every time.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, body, category]
              properties:
                subject:
                  type: string
                  minLength: 4
                  maxLength: 120
                body:
                  type: string
                  minLength: 10
                  maxLength: 10000
                category:
                  type: string
                  enum: [general, server, bot, billing, abuse]
      responses:
        '201':
          description: The ticket that was opened.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Ticket'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: '`bot_not_linked`. That Discord id belongs to no account.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '500': { $ref: '#/components/responses/Internal' }

  /internal/usage:
    post:
      tags: [internal]
      operationId: pushUsage
      summary: Node daemon pushes a batch of usage samples
      description: |
        Machine-to-machine only, guarded by the shared daemon token and
        deliberately never by `authRequired`. Nothing here reads a user id and
        nothing here is reachable from the browser.

        The direction is the point: nodes push to the API, so the API needs no
        schedule, no fan-out and no route **to** a node in order to keep the
        dashboard live. A new node is a database row plus a token.

        What this enforces:

        * the node is marked seen **first**, so a node whose instances have all
          been deleted still counts as alive;
        * at most 500 reports per push; the tail is dropped rather than the batch
          rejected, because the next push lands seconds later -- losing the
          overflow costs one sample, rejecting would cost every sample;
        * each `ref` is resolved against `daemon_ref`, never against the API's
          instance id -- the daemon addresses instances by its own opaque handle;
        * a `ref` the API no longer knows is skipped, not an error: that is the
          normal race between the daemon's snapshot and this call;
        * a `ref` belonging to a **different** node is skipped, because taking it
          would let a stale daemon overwrite the live gauge of an instance it
          does not own.

        The reported `state` is also folded back into the stored status column
        and published on the instance's events topic. Without that an instance
        would sit at whatever the API last wrote -- `installing` forever, since
        the daemon finishes provisioning on its own schedule and the API is never
        told -- and because power actions read the stored column, a stale value
        would make the start button answer `instance_busy` permanently.

        Two exceptions to that fold: `suspended` and `deleting` are owned by the
        API, so a node that has not yet been told about a suspension cannot
        report its way out of one; and a state this API does not recognise is
        logged and ignored rather than stored, because the daemon may speak a
        newer vocabulary than the handlers reading the column.

        Answers with counts instead of 204 so an operator's `curl` can see a
        drifted daemon. The daemon itself only reads the status code.
      security:
        - daemonToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UsagePushRequest'
      responses:
        '200':
          description: How much of the batch was ingested.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageIngestResult'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '500': { $ref: '#/components/responses/Internal' }

  /internal/nodes/{id}/heartbeat:
    parameters:
      - name: id
        in: path
        required: true
        description: Node id as configured on the daemon.
        schema:
          type: string
        example: node-fsn1-01
    post:
      tags: [internal]
      operationId: nodeHeartbeat
      summary: Node daemon reports that it is alive
      description: |
        Covers the idle node: one with no instances never pushes usage, and
        without a ping of its own it would age into `offline` while perfectly
        healthy.

        The row is read before it is touched, so a typo in the daemon's node id
        surfaces as a 404 instead of an `UPDATE` that matches nothing and reports
        success. Guarded by the shared daemon token.
      security:
        - daemonToken: []
      responses:
        '204':
          description: Node marked online.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/Internal' }

  # --- admin ----------------------------------------------------------------

  /admin/overview:
    get:
      tags: [admin]
      operationId: adminOverview
      summary: Operator landing page
      description: |
        The numbers that tell an operator whether today is normal: account count,
        instances by lifecycle status, tickets by status, credits issued and
        spent (total and last 24h), how many instances are actually reporting
        usage, and the live health of every node.

        Behind `adminRequired`, which re-reads the account row instead of
        trusting the `role` claim: a token issued before a demotion stays valid
        for its full TTL, so checking the row is what actually revokes access.
      responses:
        '200':
          description: Platform snapshot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Overview'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users:
    get:
      tags: [admin]
      operationId: adminListUsers
      summary: Search accounts
      description: |
        Paginated account search. `suspended` is a tri-state: an absent parameter
        means "don't care", while `?suspended=false` is a real filter for active
        accounts only. `q` matches the address and the display name.
      parameters:
        - name: q
          in: query
          required: false
          description: Free-text search over email and display name.
          schema:
            type: string
          example: hoffmann
        - name: role
          in: query
          required: false
          description: Restrict to one role.
          schema:
            $ref: '#/components/schemas/Role'
          example: user
        - name: suspended
          in: query
          required: false
          description: Absent means both. `true` lists only suspended accounts, `false` only active ones.
          schema:
            type: boolean
          example: true
        - $ref: '#/components/parameters/Limit25'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: One page of accounts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserPage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}:
    parameters:
      - $ref: '#/components/parameters/UserId'
    get:
      tags: [admin]
      operationId: adminGetUser
      summary: One account with instances and recent ledger
      description: |
        Everything an operator needs to answer a support question without opening
        five tabs: the account, its credit balance, its instances and its most
        recent credit transactions.
      responses:
        '200':
          description: Account detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserDetail'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [admin]
      operationId: adminDeleteUser
      summary: Delete an account on request
      description: |
        The deletion request that arrives by mail instead of through the
        account's own settings. It goes through the same service call the user's
        own deletion uses, so there is one teardown to keep correct: instances
        stopped and removed from their nodes, databases dropped, then the row
        soft-deleted.

        An admin account is refused. Not a rail against malice, which an admin
        could route around by demoting first, but against the misclick that
        empties the only account able to undo it.
      responses:
        '204':
          description: Account and everything it owned are gone.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The account is an admin.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/sessions:
    parameters:
      - $ref: '#/components/parameters/UserId'
    get:
      tags: [admin]
      operationId: adminListUserSessions
      summary: Where an account is logged in
      description: |
        The lesser tool next to suspending. Suspending kills every session and
        locks the account out entirely; a stolen phone needs one device signed
        out, not the owner locked out of their own servers.

        Same shape the account's own session list uses, so the operator sees
        exactly what the user sees.
      responses:
        '200':
          description: The account's sessions, active and revoked.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Session'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [admin]
      operationId: adminRevokeAllUserSessions
      summary: Sign an account out everywhere
      description: |
        Every session, without suspending the account. The user can log in again
        immediately; what they cannot do is keep a session somebody else is
        holding.
      responses:
        '204':
          description: All sessions revoked.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/sessions/{sessionId}:
    parameters:
      - $ref: '#/components/parameters/UserId'
      - name: sessionId
        in: path
        required: true
        description: Session id, checked against the account in the path.
        schema:
          type: string
    delete:
      tags: [admin]
      operationId: adminRevokeUserSession
      summary: Sign out one device
      description: |
        The session is checked against the account in the path rather than
        trusted from it. A session id is a uuid an operator can paste, and
        pasting the wrong one must not sign out a stranger.
      responses:
        '204':
          description: Session revoked.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: No such account, or that session does not belong to it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/totp/disable:
    parameters:
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [admin]
      operationId: adminDisableUserTOTP
      summary: Take the second factor off an account
      description: |
        The most common support request a platform with TOTP gets, and the one
        with no self-service answer left once the recovery codes are gone too.

        It takes the sessions with it. A second factor removed while a stolen
        session is still open would be a downgrade for the attacker's benefit.

        `totp_not_enabled` when the account has no second factor to remove.
      responses:
        '204':
          description: Second factor removed, all sessions revoked.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The account has no second factor (`totp_not_enabled`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/verify-email:
    parameters:
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [admin]
      operationId: adminVerifyUserEmail
      summary: Mark an address confirmed without the mail
      description: |
        The mail may never arrive. With mail disabled nothing leaves the server
        at all, and even with it on a confirmation lands in spam folders nobody
        reads. The operator has usually just spoken to the person.

        Pending verification tokens are invalidated, so an old link in an inbox
        stops working.
      responses:
        '204':
          description: Address marked verified.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Already verified.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/reset-link:
    parameters:
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [admin]
      operationId: adminCreateResetLink
      summary: Mint a password reset token and hand it back
      description: |
        Answers with the token rather than mailing it. On a deployment that
        sends no mail there is otherwise no answer at all to "I clicked forgot
        password and nothing came"; the operator passes the link on through the
        channel they already verified the person on.

        It is an ordinary reset token: it expires on its own and is consumed on
        first use. Issuing one invalidates any earlier reset token, so handing
        out a second link cancels the first.

        The token is deliberately absent from the audit row it writes. A log
        line that other staff can read is the wrong place for a working password
        reset.
      responses:
        '200':
          description: The token. Build the link as `/reset-password?token=<token>`.
          content:
            application/json:
              schema:
                type: object
                required: [token]
                properties:
                  token:
                    type: string
                    example: 8Kx2mQ7vTn4pRs9wZa1cYb3dEf6gHj0k
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/email:
    parameters:
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [admin]
      operationId: adminChangeUserEmail
      summary: Correct the address an account registered with
      description: |
        The typo at signup, and the only case where nobody can help themselves:
        the confirmation goes to an address that does not exist.

        The new address starts unverified, because an operator changing it is
        not the same as the person proving they own it, and every session is
        revoked, because changing the address is exactly what an account
        takeover does first. Pending verification and reset tokens are dropped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        '204':
          description: Address changed, sessions revoked, verification cleared.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Unchanged, or another account already uses that address (`email_taken`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/me:
    get:
      tags: [admin]
      operationId: adminMe
      summary: What the caller may do here
      description: |
        The caller's role, their permission set and the full catalogue of
        permissions that could be granted.

        The operator surface renders from this rather than from the account's
        role. With permissions there is no longer one admin view, and a page that
        draws a button the API will refuse is a worse answer than one that leaves
        it out.

        Behind the same gate as the rest of `/admin`, so reaching it already
        means having some access. It needs no permission of its own.
      responses:
        '200':
          description: The caller's rights.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdminMe'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/team-roles:
    get:
      tags: [admin]
      operationId: adminListTeamRoles
      summary: Every team role
      description: |
        The roles an operator has made, in page order. A role is a public title,
        not a permission: nothing anywhere is granted from this table, and
        `users.role` stays the one thing that decides what somebody may do.
      responses:
        '200':
          description: The roles, by rank then name.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TeamRole'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: adminCreateTeamRole
      summary: Make a team role
      description: |
        Creates a title that can then be given to accounts. `rank` orders the
        page, low first; `description` is the sentence under the name and may be
        empty, because a role called "Betrieb" explains itself.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TeamRoleRequest'
      responses:
        '201':
          description: The created role.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TeamRole'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Validation' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/team-roles/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: The role's id.
        schema: { type: string, format: uuid }
    put:
      tags: [admin]
      operationId: adminUpdateTeamRole
      summary: Rename or reorder a team role
      description: |
        A full replacement of name, description and rank. Renaming here renames
        it for everybody who holds it, which is the reason a role is its own row
        rather than a title typed onto each account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TeamRoleRequest'
      responses:
        '200':
          description: The updated role.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TeamRole'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Validation' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [admin]
      operationId: adminDeleteTeamRole
      summary: Remove a team role
      description: |
        Drops the role and takes it off everyone who held it, in one
        transaction. The accounts themselves are untouched -- they lose the
        listing on /team, nothing else. The audit row records how many people
        that was.
      responses:
        '204':
          description: Role removed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/team-role:
    parameters:
      - $ref: '#/components/parameters/UserId'
    put:
      tags: [admin]
      operationId: adminSetTeamRole
      summary: Give an account a team role, or take it away
      description: |
        Puts the account on /team under that role. An empty or omitted `role_id`
        takes it off again, which is why the body is optional: "no role" is the
        natural shape of the request that removes one.

        A suspended account is refused (`user_suspended` in `details`). This
        grants nothing: the account's `role` is untouched, and a title on the
        website is not a way to hand out admin.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TeamRoleAssignment'
      responses:
        '204':
          description: Assignment stored.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/suspend:
    parameters:
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [admin]
      operationId: adminSuspendUser
      summary: Suspend an account
      description: |
        Stops every instance the account owns, blocks login and mails the stated
        reason. The reason may be empty -- a suspension with no stated reason is
        still better than one that fails to apply -- but it is stored and shown.

        Refuses any account with the `admin` role (`cannot_suspend_admin` in
        `details`), which is what stops an operator from suspending themselves or
        their colleagues out of the last seat. That rule lives in the service, so
        it holds for every caller and not just this route.

        Writes an audit row naming the acting admin's real email, re-read from
        the database by `adminRequired` rather than taken from the token.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdminReasonRequest'
      responses:
        '204':
          description: Account suspended.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/unsuspend:
    parameters:
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [admin]
      operationId: adminUnsuspendUser
      summary: Lift a suspension
      description: |
        Clears `suspended_at` and the stored reason so the account can log in
        again. Instances are **not** started automatically: the owner decides
        what to bring back up. Audited.
      responses:
        '204':
          description: Suspension lifted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/credits:
    parameters:
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [admin]
      operationId: adminGrantCredits
      summary: Grant or deduct credits
      description: |
        Writes one attributable ledger row. A **negative** amount is a
        correction, not an error: it is routed through the spend path so the
        ledger keeps a signed row with the same invariants, and a deduction below
        zero is refused with `insufficient_credits`.

        The `note` is stored on the transaction and is what a user sees in their
        ledger, so it should say why. Audited with the acting admin's email.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdminCreditsRequest'
      responses:
        '200':
          description: The written ledger row.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditTransaction'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/{id}/role:
    parameters:
      - $ref: '#/components/parameters/UserId'
    post:
      tags: [admin]
      operationId: adminSetRole
      summary: Promote or demote an account
      description: |
        Sets `user` or `admin`. Refuses a role change targeting the **caller's
        own** account (`cannot_change_own_role` in `details`), so an operator
        cannot demote themselves out of the last admin seat. Existing access
        tokens keep their old `role` claim until they expire, but every admin
        route re-reads the row, so a demotion takes effect on the next request
        rather than at the next login.

        Audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdminRoleRequest'
      responses:
        '204':
          description: Role changed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/instances:
    get:
      tags: [admin]
      operationId: adminListInstances
      summary: Search instances across all accounts
      description: |
        The unscoped instance list, filterable by kind, lifecycle status, owner
        and node. This is how an operator finds every instance on a node they are
        about to drain.
      parameters:
        - name: kind
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/Kind'
          example: game
        - name: status
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/InstanceStatus'
          example: suspended
        - name: user_id
          in: query
          required: false
          description: Restrict to one owner.
          schema:
            type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        - name: q
          in: query
          required: false
          description: |
            Substring match on the instance name. The repository has always
            supported it and the handler dropped it, so the one lookup an
            operator actually performs -- a name out of a ticket, never a uuid --
            was the one filter that did not exist.
          schema:
            type: string
          example: testserver
        - name: node_id
          in: query
          required: false
          description: Restrict to one node.
          schema:
            type: string
          example: node-fsn1-01
        - $ref: '#/components/parameters/Limit25'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: One page of instances.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstancePage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/instances/{id}/unsuspend:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [admin]
      operationId: adminUnsuspendInstance
      summary: Undo a suspension without charging the owner
      description: |
        The counterpart nobody built. Suspending existed from the start and the
        only route back was the owner spending credits on a renewal, which is
        the wrong bill when the suspension was the operator's mistake or the
        resolution of a ticket that went the user's way.

        The expiry is pushed far enough out that the scheduler does not suspend
        it again on its next tick. With `days` at zero and an expiry already in
        the past, the instance gets its plan's own period back; otherwise the
        given days are added. Without that, unsuspending lasts less than an hour.

        `days` is capped at 90. This is compensation, not a way to hand somebody
        a year, and granting twice leaves two audit rows -- which is the right
        shape for something that should be visible when it repeats.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                days:
                  type: integer
                  minimum: 0
                  maximum: 90
                  description: Extra days on top. Zero means "just make it live again".
      responses:
        '204':
          description: Unsuspended, with an expiry the scheduler will not undo.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The instance is not suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/instances/{id}/grant-time:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [admin]
      operationId: adminGrantInstanceTime
      summary: Move an expiry out without spending the owner's credits
      description: |
        The compensation lever. A node that was down for a day cost every
        instance on it a day of the period its owner already paid for, and the
        only alternative was granting credits and hoping the user noticed they
        should renew.

        Counted from whichever is later, the current expiry or now, the same
        rule a renewal uses: two days of goodwill on an instance that expired a
        month ago should not leave it 28 days expired. A grant on a suspended
        instance brings it back, because leaving the deletion deadline in place
        would let the scheduler delete something whose expiry is now weeks away.

        Between 1 and 90 days.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [days]
              properties:
                days:
                  type: integer
                  minimum: 1
                  maximum: 90
      responses:
        '200':
          description: The instance with its new expiry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Instance'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/instances/{id}/move:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [admin]
      operationId: moveInstance
      summary: Move an instance to another node
      description: |
        Admin only, and it stays that way. The reason to move is capacity, which
        is the operator's problem; a user pressing this would be stopping their
        own server for several minutes and changing its address to solve
        something they cannot see.

        It exists because node selection happened exactly once, at creation.
        Without it every imbalance is permanent, and so is a node that has to be
        drained before it is rebuilt.

        **The instance is stopped for the whole move**, and this request is held
        open until it finishes -- minutes for a large world. There is no live
        migration and there should not be: a world copied while the server is
        writing to it arrives corrupt.

        **The address changes.** Port numbers are allocated per node, so the
        instance answers on a different port afterwards. The response names both
        so the operator can tell the owner, and the owner's own activity log
        carries the row.

        Nothing new reaches the daemon for it: a backup on the source, a stream
        through the API, an upload and an unpack on the target. The bytes pass
        through this process rather than going node to node, which on a free
        tier whose largest disk is ten gigabytes is the cheaper trade than a new
        endpoint and a credential one node holds for another.

        Every failure before the database is rewritten leaves the instance
        working where it already was: the half-built copy on the target is
        removed and a server that was running is started again. After that
        point, a source node that cannot be reached leaves an orphan directory
        that is logged rather than a move that half happened.

        **The port travels with the instance.** Port ranges repeat across nodes
        on purpose, and the archive carries the instance's own config file --
        unpacked over the one the target just wrote. Renumbering would leave the
        file saying one port and the database saying another, and the server
        would come up healthy on a port nobody was told about. So the number is
        kept, and a move onto a node already using it is refused rather than
        renumbered. Only the host changes.

        Refused with `node_unavailable` and a `details.reason` when the target
        cannot take it: `node_inactive`, `node_offline`, `wrong_kind`, `no_ram`,
        `no_disk`, `port_taken` or `port_out_of_range`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [node_id]
              properties:
                node_id:
                  type: string
                  description: The node to move to. Must not be the one the instance is already on.
                  example: 6f1c4a2e-9d3b-4f70-8c21-5b7ae0f39c14
      responses:
        '200':
          description: Moved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MoveResult'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            The instance is suspended or busy, the target cannot take it, or one
            half of the move timed out waiting for a node.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '503':
          description: A node did not answer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500': { $ref: '#/components/responses/Internal' }

  /admin/instances/{id}/power:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [admin]
      operationId: adminPowerInstance
      summary: Start, stop or restart somebody else's instance
      description: |
        Stopping a server that is hammering a node, without taking it away from
        its owner. It goes through the owner's own power path rather than around
        it, so the node call, the transitional status and the events socket
        behave identically.

        The difference is who is recorded. The instance's activity log names the
        operator, which is exactly what the owner needs to see when their server
        stopped and they did not stop it.

        202, like the owner-facing power route: the node has been told, and the
        container reaches the state afterwards.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InstancePowerRequest'
      responses:
        '202':
          description: The node has been told.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/instances/{id}/suspend:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [admin]
      operationId: adminSuspendInstance
      summary: Suspend one instance
      description: |
        Stops the container, tells the daemon to suspend it and stamps
        `suspended_at`. Unlike a lifecycle suspension this sets no
        `delete_after`: an abuse suspension is a hold, not a countdown to
        deletion.

        The owner can still renew, which is the documented way out of a
        lifecycle suspension -- an operator holding an instance for abuse should
        expect the user to reappear in a ticket, which is the intent. Audited
        with the reason.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdminReasonRequest'
      responses:
        '204':
          description: Instance suspended.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/instances/{id}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    delete:
      tags: [admin]
      operationId: adminDeleteInstance
      summary: Delete any instance
      description: |
        The unscoped counterpart of `DELETE /instances/{id}`: removes the
        instance from its node and soft-deletes the row regardless of who owns
        it. No grace period, no email. Audited with the target's owner and name.
      responses:
        '204':
          description: Instance deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/plans:
    get:
      tags: [admin]
      operationId: adminListPlans
      summary: Every plan, retired ones included
      description: |
        Unlike `GET /plans` this lists inactive rows too: an operator has to be
        able to find the plan they deactivated in order to edit it back. Not
        paginated -- the catalogue is small by design.
      responses:
        '200':
          description: All plans.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Plan'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: adminCreatePlan
      summary: Create a plan
      description: |
        The plan editor's payload. Limits are validated for internal
        consistency before the row is written, because a plan whose RAM is zero
        would create instances nothing can run in: `name` required, `kind` one of
        `game`/`bot`, `ram_mb` >= 128, `cpu_percent` >= 10, `disk_mb` >= 256,
        `max_instances` >= 1, `renewal_days` >= 1, `renewal_cost` >= 0. All
        failures come back as one 422 listing every offending field.

        `key` is unique and is what instances reference; a duplicate answers
        `conflict`. Audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlanInput'
      responses:
        '201':
          description: The created plan.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Plan'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/plans/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Plan id.
        schema:
          type: string
        example: 0c5e4a91-3f7d-4b28-8e6a-1d9c2f80b374
    put:
      tags: [admin]
      operationId: adminUpdatePlan
      summary: Replace a plan
      description: |
        A full replacement, not a patch: the editor sends back the object it
        read, and the same consistency checks as creation run again.

        Editing a plan changes what **existing** instances are measured against
        on their next renewal and in every usage bar, because nothing is copied
        onto the instance row except the resources it was provisioned with.
        Setting `active: false` retires the plan from the public catalogue
        without touching the instances already on it. Audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlanInput'
      responses:
        '200':
          description: The updated plan.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Plan'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/eggs:
    get:
      tags: [admin]
      operationId: adminListEggs
      summary: Every template, including the deactivated ones
      description: |
        Unlike `GET /eggs` this includes templates with `active: false`: an
        operator who turned one off has to be able to find it in order to turn
        it back on.
      responses:
        '200':
          description: The full catalogue.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Egg'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: adminCreateEgg
      summary: Add a template
      description: |
        Adds a template without a deploy. The image it names has to exist and,
        for a game template, `runtime` has to be one `GET /admin/runtimes`
        lists -- neither is created here, but both are checked here, which is
        the point: a template naming a runtime with no provider is refused now
        rather than discovered a week later as an empty version list that reads
        like an upstream outage.

        `key` and `kind` are settable exactly once, here. The key is lowercased
        and must match `^[a-z0-9]+(?:-[a-z0-9]+)*$`; it is written into every
        provisioned instance's spec on disk, which is why `PUT` refuses to
        change it. A key that already exists comes back as a `key`/`taken`
        validation field rather than a constraint violation.

        Writes an `admin.egg_create` audit row.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EggCreateRequest'
      responses:
        '201':
          description: The new template.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Egg'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/eggs/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Egg id.
        schema:
          type: string
    put:
      tags: [admin]
      operationId: adminUpdateEgg
      summary: Edit a template
      description: |
        There is no POST beside this. A new template needs an image that can run
        it and a provider that knows where its downloads live, neither of which
        happens in a form, so adding one stays a seed and a deploy.

        `key` and `kind` are not in the payload and cannot be changed: the key
        is written into every provisioned instance's spec on disk, and the kind
        decides which product a template belongs to.

        Two validations are not formalities. A **game** template's `runtime`
        must be one `GET /admin/runtimes` lists -- a runtime without a provider
        leaves the template in the creation form with an empty version list,
        which reads like an upstream outage rather than a typo. And
        `config_json` must be an object with a `variables` array: the runtime
        decoder tolerates a broken descriptor by yielding no variables, which is
        right for a live template and wrong here, because it would let a typo
        silently empty the creation form.

        Bot templates deliberately have no provider and are not checked against
        one; they have no versions to pick.

        Writes an `admin.egg_update` audit row with the fields before and after.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EggUpdateRequest'
      responses:
        '200':
          description: The edited template.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Egg'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/runtimes:
    get:
      tags: [admin]
      operationId: adminListRuntimes
      summary: Runtimes a game template may name
      description: |
        Every runtime `internal/provider` implements. The egg editor renders it
        as a picker rather than a text field, because the set is small, fixed by
        the build, and a typo in it is not recoverable from the UI it breaks.

        The order is a Go map's, which is deliberately random; sort it before
        display.
      responses:
        '200':
          description: Runtime identifiers.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
              example: [minecraft-java, minecraft-purpur, minecraft-bedrock]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/support-pin:
    post:
      tags: [admin]
      operationId: adminFindBySupportPIN
      summary: Find the account behind a support PIN
      description: |
        The operator's end of the PIN: type in what the caller read out and find
        out whose account it is. An address in a ticket is a claim; this is the
        proof, and it is the step that belongs before anybody opens anything.

        A POST rather than a GET with a query, so the code stays out of access
        logs, browser history and `Referer` headers.

        A code that never existed and a code that expired an hour ago are the
        same `404`. Distinguishing them would say something about which accounts
        exist.

        Writes an `admin.support_pin_lookup` row naming the account it resolved
        to.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pin]
              properties:
                pin:
                  type: string
                  example: '40318827'
      responses:
        '200':
          description: The account holding that PIN.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/users/export.csv:
    get:
      tags: [admin]
      operationId: adminExportUsers
      summary: The account list as CSV
      description: |
        Honours the same filters as `GET /admin/users`: an operator who filtered
        to suspended accounts and then exported means the suspended accounts.
        There is no paging -- the export is the whole matching set.

        Semicolon-separated, not comma. The file is opened in Excel on a German
        Windows machine, where a comma-separated file lands in one column and
        reads as a broken export.

        Deliberately absent: the credit balance and the instance count. Both are
        one query per row, and a column that turns a large export into as many
        round trips is a report that times out. They are on the user detail page,
        where they are actually read.

        This hands somebody a file with every address on the platform in it, so
        it writes an `admin.users_export` audit row naming who took it and how
        many rows they got.
      parameters:
        - name: q
          in: query
          description: Substring of the address or the display name.
          schema:
            type: string
        - name: role
          in: query
          schema:
            type: string
            enum: [user, admin]
        - name: suspended
          in: query
          description: Absent means "don't care"; `false` is a real filter.
          schema:
            type: boolean
      responses:
        '200':
          description: The export.
          content:
            text/csv:
              schema:
                type: string
              example: |
                id;email;display_name;role;locale;email_verified_at;suspended_at;suspended_reason;totp_enabled;referral_code;referred_by;last_login_at;created_at
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/status:
    get:
      tags: [admin]
      operationId: adminPlatformStatus
      summary: What is quietly broken
      description: |
        Everything here was previously only in `journalctl`, which means it was
        visible to whoever was already logged into the box and to nobody else.
        Each field is on the page because its failure is silent:

        - a scheduler that stopped ticking suspends nobody, deletes nobody and
          fires no backup, while the platform looks entirely healthy from outside
        - a MariaDB that died after startup answers every existing connection
          string with a timeout and nothing else changes
        - `MAIL_ENABLED=false` means no verification, no reset and no renewal
          reminder leaves the machine, which reads to a user as "the mail never
          arrived"
        - a node that stopped reporting still shows the instances it had

        The database is pinged on each request rather than reported from the
        state it had at startup -- that is the whole difference between this and
        the log line printed when the process began.

        `warnings` is the part to render. The rest is detail behind it.
      responses:
        '200':
          description: Current state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformStatus'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/platform:
    get:
      tags: [admin]
      operationId: adminGetPlatformSettings
      summary: The platform switches
      responses:
        '200':
          description: Current settings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformSettings'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    put:
      tags: [admin]
      operationId: adminUpdatePlatformSettings
      summary: Open or close registration
      description: |
        A full replacement, not a patch.

        Closing registration exists for one situation: an account farm is
        running. Rate limits and Turnstile raise the cost of the next account;
        neither stops the one being created right now, and neither can be turned
        off again without a deploy. This can.

        While closed, `POST /auth/register` answers `403 registration_closed`
        before it checks the password, the referral code or anything else.

        `registration_note` is shown to whoever hits the closed door. Without it
        the register page says "closed" and every one of those people opens a
        ticket asking when it reopens.

        Writes an `admin.platform_update` audit row.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlatformSettings'
      responses:
        '200':
          description: The stored settings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformSettings'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/platform/alert-test:
    post:
      tags: [admin]
      operationId: adminTestAlertWebhook
      summary: Post a line to the alert webhook now
      description: |
        A webhook is otherwise only ever exercised by an outage, which is the
        one moment nobody is in a position to debug a pasted URL. This posts a
        single message immediately and answers with what Discord said.

        `502 webhook_failed` means Discord refused. It is its own code so the
        operator can tell "your URL is wrong" apart from "the platform broke".
      responses:
        '204':
          description: Delivered.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: '`validation_failed`. No webhook is configured yet.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: '`webhook_failed`. Discord did not accept the message.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: webhook_failed
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /news:
    get:
      tags: [catalog]
      operationId: listNews
      summary: News and changelog
      description: |
        Published entries, newest first, both languages in every row -- the
        site picks the one it renders, so one cached answer serves both.
        Drafts and entries dated in the future are not in this list.
      parameters:
        - name: limit
          in: query
          description: At most this many entries; 0 or absent is all of them.
          schema: { type: integer, minimum: 0 }
      responses:
        '200':
          description: The published entries.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/NewsPost'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /news/{slug}:
    parameters:
      - name: slug
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [catalog]
      operationId: getNews
      summary: One entry
      description: A draft answers 404, exactly like a slug that never existed.
      responses:
        '200':
          description: The entry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NewsPost'
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/news:
    get:
      tags: [admin]
      operationId: adminListNews
      summary: Every entry, drafts included
      responses:
        '200':
          description: All entries, newest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/NewsPost'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: adminCreateNews
      summary: Write an entry
      description: Needs `news.write`. Without `published_at` the entry is a draft.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewsRequest'
      responses:
        '201':
          description: The entry as stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NewsPost'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/news/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    put:
      tags: [admin]
      operationId: adminUpdateNews
      summary: Edit an entry
      description: A full replacement, both languages; clearing `published_at` unpublishes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewsRequest'
      responses:
        '200':
          description: The edited entry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NewsPost'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [admin]
      operationId: adminDeleteNews
      summary: Remove an entry
      responses:
        '204':
          description: Removed.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/orders:
    get:
      tags: [admin]
      operationId: adminListOrders
      summary: Every KVM machine, desk-wide
      description: |
        Needs `orders.read`. The supplier's service id is included here and
        nowhere the customer can see it. Dedicated requests are tickets, not
        rows in this list.
      parameters:
        - name: status
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer }
        - name: offset
          in: query
          schema: { type: integer }
      responses:
        '200':
          description: A page of machines with their owner's address.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/orders/{id}/renew:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [admin]
      operationId: adminRenewOrder
      summary: Extend a machine without charging
      description: |
        Needs `orders.write`. Extends at the supplier by `months` times thirty
        days and moves the renewal date; nothing is booked on the customer's
        balance -- this is the operator's gift, audited with the note.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [months]
              properties:
                months: { type: integer, minimum: 1, maximum: 12 }
                note: { type: string }
      responses:
        '200':
          description: The new renewal date.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503':
          description: The supplier could not extend the service.

  /admin/orders/{id}/cancel:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [admin]
      operationId: adminCancelOrder
      summary: Mark a machine cancelled
      description: |
        Needs `orders.write`. Sets our status only: the supplier has no
        cancel call, a service that is not extended runs out on its renewal
        date.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                note: { type: string }
      responses:
        '204':
          description: Marked.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/invoices:
    get:
      tags: [admin]
      operationId: adminListInvoices
      summary: Every invoice, newest number first
      description: Needs `economy.read`. Each row carries its owner's address.
      parameters:
        - name: limit
          in: query
          schema: { type: integer }
        - name: offset
          in: query
          schema: { type: integer }
      responses:
        '200':
          description: A page of invoiced ledger rows.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/invoices/{no}/pdf:
    parameters:
      - name: no
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [admin]
      operationId: adminInvoicePDF
      summary: One invoice as PDF, any account
      responses:
        '200':
          description: The PDF.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /coupons/active:
    get:
      tags: [catalog]
      operationId: listActiveCoupons
      summary: The promotion codes open today
      description: Codes whose yearly window contains today. What the banner shows.
      security: []
      responses:
        '200':
          description: Open codes.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/CouponPublic'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /coupons/check:
    post:
      tags: [credits]
      operationId: checkCoupon
      summary: What a code is worth for this account
      description: |
        Signed in, because the answer is per account: a code already redeemed
        this calendar year answers `coupon_used` for this caller only.
        `bonus_cents` is the balance the given payment would add on top.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
                amount_cents: { type: integer }
      responses:
        '200':
          description: The code, its percent, its end and the bonus for the amount.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: Already redeemed this year (`coupon_used`).
        '422':
          description: Unknown, switched off or outside its window (`coupon_invalid`).
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/coupons:
    get:
      tags: [admin]
      operationId: adminListCoupons
      summary: Every promotion code
      responses:
        '200':
          description: All codes.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Coupon'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: adminCreateCoupon
      summary: Add a code
      description: Needs `economy.write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CouponRequest'
      responses:
        '201':
          description: The code as stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Coupon'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/coupons/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    put:
      tags: [admin]
      operationId: adminUpdateCoupon
      summary: Edit a code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CouponRequest'
      responses:
        '200':
          description: The edited code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Coupon'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [admin]
      operationId: adminDeleteCoupon
      summary: Remove a code
      responses:
        '204':
          description: Removed. Past redemptions stay in the ledger.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/ticket-templates:
    get:
      tags: [admin]
      operationId: adminListTicketTemplates
      summary: Canned staff replies
      description: |
        Shared across the whole desk rather than owned by an author: the point
        is that two people answering the same question answer it the same way.

        `category` is a **sort hint, not a filter**. Templates matching the
        ticket's category come first, then the ones with no category, then the
        rest. Filtering would hide the general templates -- the ones used most
        -- from every categorised ticket, which is most of them.
      parameters:
        - name: category
          in: query
          description: The open ticket's category, to sort the relevant ones first.
          schema:
            type: string
            enum: [general, server, bot, billing, abuse]
      responses:
        '200':
          description: Every template, best match first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TicketTemplate'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: adminCreateTicketTemplate
      summary: Save a reply as a template
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketTemplateRequest'
      responses:
        '201':
          description: The new template.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketTemplate'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/ticket-templates/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Template id.
        schema:
          type: string
    put:
      tags: [admin]
      operationId: adminUpdateTicketTemplate
      summary: Edit a template
      description: |
        A full replacement. The form shows every field, so a merge would make
        "clear this field" impossible to express.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketTemplateRequest'
      responses:
        '200':
          description: The edited template.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketTemplate'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    delete:
      tags: [admin]
      operationId: adminDeleteTicketTemplate
      summary: Remove a template
      responses:
        '204':
          description: Removed. Replies already sent are unaffected -- the body was copied, not referenced.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/nodes:
    get:
      tags: [admin]
      operationId: adminListNodes
      summary: Nodes with live health
      description: |
        Served from the health cache rather than the `nodes` table: the operator
        opens this page to see which nodes are answering, and a row read straight
        from the database cannot tell them that. Carries allocated versus total
        RAM and disk, the running instance count and the node's load average.

        The daemon URL and token are never serialised.
      responses:
        '200':
          description: Node health, one entry per active node.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/NodeHealth'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: adminCreateNode
      summary: Register a node
      description: |
        Adds a machine to the pool. `name` and `daemon_url` are required and
        `port_range_to` must exceed `port_range_from`, otherwise the allocator
        would have no port to hand out. The daemon token is sealed with AES-GCM
        before it is stored and never comes back out.

        The node starts as `offline` and flips to `online` on its first heartbeat
        or usage push, so registering it wrong shows up as a node that never
        comes up rather than as a node that silently takes no instances. Audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NodeInput'
      responses:
        '201':
          description: The registered node.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Node'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/nodes/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Node id.
        schema:
          type: string
        example: node-fsn1-01
    get:
      tags: [admin]
      operationId: adminGetNode
      summary: The stored node record, for the editor
      description: |
        `GET /admin/nodes` answers the public health shape, which carries no
        `fqdn`, no `daemon_url` and no port range. The editor needs all three,
        because `PUT` replaces the whole record: without this call it opened
        with those fields blank, and changing a RAM ceiling meant retyping the
        daemon URL from memory. A typo there silently repoints a live node at
        nothing.

        `daemon_token` is blanked in the response and never returned. An empty
        token on `PUT` already means "keep the current one", so the editor has
        no reason to hold a secret it cannot show.
      responses:
        '200':
          description: The node as stored, without its token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Node'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/Internal' }
    put:
      tags: [admin]
      operationId: adminUpdateNode
      summary: Update a node
      description: |
        A full replacement with one exception: an **empty** `daemon_token` means
        "keep the current one", so the editor never has to round-trip a secret it
        is not shown.

        Setting `active: false` drains the node -- the placement logic stops
        choosing it for new instances while the ones already on it keep running.
        Lowering `max_ram_mb` below what is already allocated is allowed and
        simply means the node accepts nothing new. Audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NodeInput'
      responses:
        '200':
          description: The updated node.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Node'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/tickets:
    get:
      tags: [admin]
      operationId: adminListTickets
      summary: The staff inbox
      description: |
        Every ticket from every account. The only difference from
        `GET /tickets` is that the filter carries no user id -- reading and
        replying to a single thread stays on the `/tickets` routes, which decide
        scope from the caller's role.
      parameters:
        - name: status
          in: query
          required: false
          description: Restrict to one status. Absent means all.
          schema:
            $ref: '#/components/schemas/TicketStatus'
          example: customer_reply
        - name: category
          in: query
          required: false
          description: Restrict to one category.
          schema:
            type: string
          example: billing
        - name: priority
          in: query
          required: false
          description: Restrict to one priority.
          schema:
            type: string
          example: high
        - name: q
          in: query
          required: false
          description: |
            Substring match on the subject. These three came with the filter
            from the beginning and the staff inbox passed none of them, so a
            desk with more than one page of tickets could neither pull the open
            billing ones nor find a thread by its subject.
          schema:
            type: string
          example: mysql
        - $ref: '#/components/parameters/Limit20'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: One page of tickets.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketPage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/billing:
    get:
      tags: [admin]
      operationId: adminGetBilling
      summary: The billing mode and gateway settings
      description: |
        Which billing model the platform runs -- credits, money settled by hand,
        or money through a gateway -- plus the credit wind-down deadline and the
        gateway credentials' state. Sealed secrets are reported as set or not
        set, never returned: the editor sends an empty field to keep a stored
        secret, exactly the node daemon-token contract.
      responses:
        '200':
          description: Current billing configuration, secrets blanked.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingView'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    put:
      tags: [admin]
      operationId: adminUpdateBilling
      summary: Switch the billing mode
      description: |
        Sets the mode and the gateway credentials. Anything but `credits` starts
        the credit wind-down: earning stops immediately, ordering keeps working
        until `credit_order_deadline` and refuses with `credit_orders_closed`
        after it. Existing balances keep paying renewals either way -- the
        wind-down never confiscates what was already earned.

        Secret fields left empty keep their stored value; `clear_stripe` /
        `clear_paypal` remove a gateway's credentials entirely. The stored
        config is echoed back, secrets blanked. Audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BillingInput'
      responses:
        '200':
          description: The stored configuration after the change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingView'
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
  /admin/credits/config:
    get:
      tags: [admin]
      operationId: adminGetCreditConfig
      summary: The credit economy
      description: |
        The full economy, including the operator knobs the member-facing
        `GET /credits` deliberately projects away: the starter grant and the
        per-instance boost cap.
      responses:
        '200':
          description: Current credit configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditConfig'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
    put:
      tags: [admin]
      operationId: adminUpdateCreditConfig
      summary: Replace the credit economy
      description: |
        A full replacement, not a patch: the editor sends back the object it read
        from `GET`, and the service validates the combination before saving --
        a negative amount or a zero cooldown is refused as a whole rather than
        half-applied.

        Changes take effect on the next claim, renewal or boost; nothing already
        written to the ledger is restated. The **stored** config is echoed back,
        so the client renders what actually took effect instead of what it hoped
        to set. Audited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreditConfig'
      responses:
        '200':
          description: The stored configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditConfig'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

  /admin/audit:
    get:
      tags: [admin]
      operationId: adminListAuditLog
      summary: The audit trail
      description: |
        Every operator action, newest first: who did it (id and the email re-read
        at the time of the action), what they did, which object it touched and
        from which IP. Filterable by actor, action and target.

        Append-only -- there is no endpoint that edits or deletes a row here, by
        design.
      parameters:
        - name: actor_id
          in: query
          required: false
          description: Restrict to one acting admin.
          schema:
            type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        - name: action
          in: query
          required: false
          description: Restrict to one action key.
          schema:
            type: string
          example: admin.user_suspend
        - name: target_id
          in: query
          required: false
          description: Restrict to one target object.
          schema:
            type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        - $ref: '#/components/parameters/Limit50'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: One page of audit entries.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditLogPage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        The access token from `POST /auth/login` or `POST /auth/refresh`, sent as
        `Authorization: Bearer <token>`. Short-lived and stateless: it is
        verified against the signer's public key (also served by
        `GET /auth/public-key`) and carries the subject, the role and the
        verification flag. Never sent on a WebSocket -- see `POST /ws/ticket`.
    daemonToken:
      type: http
      scheme: bearer
      description: |
        The shared secret every node daemon presents on the `/internal`
        endpoints, sent as `Authorization: Bearer <token>` and compared in
        constant time. It authenticates a machine, not a user: nothing behind it
        reads a user id, and a mismatch answers 401 with no detail.

  parameters:
    Limit20:
      name: limit
      in: query
      required: false
      description: Page size. Default 20, capped at 100 -- no client can ask for the whole table.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
      example: 20
    Limit25:
      name: limit
      in: query
      required: false
      description: Page size. Default 25, capped at 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
      example: 25
    Limit50:
      name: limit
      in: query
      required: false
      description: Page size. Default 50, capped at 200.
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
      example: 50
    Offset:
      name: offset
      in: query
      required: false
      description: Rows to skip. Combine with `total` from the response to page.
      schema:
        type: integer
        minimum: 0
        default: 0
      example: 0
    InstanceId:
      name: id
      in: path
      required: true
      description: Instance id. Resolved together with the owner, so a foreign id is a 404.
      schema:
        type: string
      example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
    ScheduleId:
      name: scheduleId
      in: path
      required: true
      description: Schedule id, scoped to the instance in the path.
      schema:
        type: string
      example: 0d0f7e21-4c2a-4f7d-9a11-5f2b8c3e77aa
    MemberUserId:
      name: userId
      in: path
      required: true
      description: |
        The member's **user id**, not the membership row's id -- a member
        removing themselves knows their own user id, and listing the members to
        find a row id is owner-only.
      schema:
        type: string
      example: 51575e4d-78d5-4e19-973b-187263ad671d
    TicketId:
      name: id
      in: path
      required: true
      description: Ticket id.
      schema:
        type: string
      example: 5e9d3c02-14ab-4f78-83c6-0a2e6b1d94f7
    UserId:
      name: id
      in: path
      required: true
      description: Account id.
      schema:
        type: string
      example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
    BackupId:
      name: backupId
      in: path
      required: true
      description: Backup id. Must belong to the instance in the path.
      schema:
        type: string
      example: a17c6e93-4b2d-49f0-85ae-c3d1078f6b52
    FilePath:
      name: path
      in: query
      required: false
      description: Path relative to the instance's data directory. Absent means the root.
      schema:
        type: string
        default: ""
      example: plugins/
    FilePathRequired:
      name: path
      in: query
      required: true
      description: Path relative to the instance's data directory. Cleaned server-side; it cannot escape the directory.
      schema:
        type: string
      example: server.properties

  responses:
    Unauthorized:
      description: |
        Missing, malformed, expired or revoked credentials. Codes:
        `unauthorized`, `token_invalid`, `token_expired`, `invalid_credentials`,
        `totp_required`, `totp_invalid`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: unauthorized
              details:
                reason: missing_token
    Forbidden:
      description: |
        Authenticated but not allowed. Codes: `forbidden`,
        `email_not_verified`, `account_suspended`. `POST /auth/register`
        answers 403 `registration_closed` without any of those, because the
        caller is not authenticated at all -- the door is.

        Every `/admin/*` route can also answer `second_factor_required`: that
        surface is shut to an account holding only a password. It is not a
        failed sign-in -- the person is who they say they are, and the account
        may simply not carry these permissions on one credential. Signing in,
        the dashboard and the page that enrols a second factor all stay open,
        so the account holder can fix it themselves.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: email_not_verified
    NotFound:
      description: |
        No such object, or one the caller does not own -- the two are deliberately
        indistinguishable. Code: `not_found`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: not_found
    Conflict:
      description: |
        The request is well formed but the current state refuses it. Codes:
        `conflict`, `email_taken`, `totp_already_enabled`,
        `daily_already_claimed`, `insufficient_credits`, `instance_suspended`,
        `instance_busy`, `instance_not_running`, `node_unavailable`,
        `plan_inactive`, `ticket_closed`, and every `limit_*` code.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: insufficient_credits
              details:
                required: 40
                balance: 12
    ValidationFailed:
      description: |
        A field is missing, malformed or out of range. Code: `validation_failed`,
        with `details.fields` naming every offending field at once so a form can
        mark all of them in one pass.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: validation_failed
              details:
                fields:
                  - field: name
                    rule: name_format
                  - field: kind
                    rule: oneof
                    param: game|bot
    RateLimited:
      description: |
        Too many requests. Code: `rate_limited`, with `retry_after_seconds` in
        `details` and the `Retry-After`, `X-RateLimit-Limit` and
        `X-RateLimit-Remaining` headers set. Three buckets exist: per IP, per
        authenticated user, and a tight per-endpoint one on the auth entry
        points.
      headers:
        Retry-After:
          description: Seconds until the window resets.
          schema:
            type: integer
        X-RateLimit-Limit:
          description: Requests allowed per minute in this bucket.
          schema:
            type: integer
        X-RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: rate_limited
              details:
                retry_after_seconds: 60
    PayloadTooLarge:
      description: |
        The body or the file exceeds its ceiling. Codes: `payload_too_large`,
        `limit_file_size_exceeded`, with the limit in `details`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: payload_too_large
              details:
                limit_bytes: 1048576
    Internal:
      description: |
        Something failed on the server. Code: `internal_error`. The developer
        message is stripped in production, so the code is the entire contract;
        correlate with `X-Request-ID`, which is echoed on every response.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: internal_error

  schemas:

    # --- the error contract ---------------------------------------------

    Error:
      type: object
      description: |
        The single failure shape of this API. Every non-2xx response on every
        operation is this object and nothing else -- success responses are the
        payload itself, so a client never unwraps twice.
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/ErrorDetail'
      example:
        error:
          code: limit_instances_reached
          details:
            limit: 1
            current: 1

    ErrorDetail:
      type: object
      required: [code]
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          description: |
            Developer-facing only, English, and absent for 5xx in production.
            Never render it: the `code` is what the frontend translates.
          example: 'instance b3f1a7d5 has no plan'
        details:
          type: object
          additionalProperties: true
          description: |
            Structured values the frontend substitutes into the translated
            message -- limits, costs, counts, `retry_after_seconds`, or `fields`
            for `validation_failed`. Which keys appear depends on the code; see
            the response descriptions on each operation.
          properties:
            fields:
              type: array
              description: Present on `validation_failed` only, listing every offending field at once.
              items:
                $ref: '#/components/schemas/ValidationField'
          example:
            limit: 1
            current: 1

    ErrorCode:
      type: string
      description: |
        Every code the API can emit. Stable forever: the frontend translation
        files key off these strings, so a value is never renamed. Exhaustive, so
        a client can switch on it without a fallback branch.
      enum:
        - internal_error
        - validation_failed
        - not_found
        - conflict
        - unauthorized
        - forbidden
        - rate_limited
        - payload_too_large
        - email_taken
        - invalid_credentials
        - email_not_verified
        - account_suspended
        - token_invalid
        - token_expired
        - totp_required
        - totp_invalid
        - totp_already_enabled
        - totp_not_enabled
        - password_weak
        - registration_closed
        - limit_instances_reached
        - limit_ram_exceeded
        - limit_cpu_exceeded
        - limit_disk_exceeded
        - limit_backups_reached
        - limit_databases_reached
        - limit_ports_reached
        - limit_schedules_reached
        - limit_file_size_exceeded
        - archive_invalid
        - domains_unavailable
        - registrar_refused
        - dns_unavailable
        - domain_taken
        - domain_price_changed
        - domain_price_below_cost
        - captcha_required
        - app_key_required
        - captcha_unavailable
        - member_unknown
        - member_exists
        - member_self
        - insufficient_credits
        - daily_already_claimed
        - credit_orders_closed
        - credit_earning_disabled
        - payment_unconfigured
        - payment_failed
        - payment_pending
        - referral_self
        - referral_unknown
        - instance_suspended
        - instance_busy
        - instance_not_running
        - instance_running
        - node_unavailable
        - databases_unavailable
        - plan_inactive
        - versions_unavailable
        - kvm_catalog_unavailable
        - dedicated_catalog_unavailable
        - kvm_unmanaged
        - kvm_sold_out
        - kvm_price_changed
        - kvm_order_failed
        - coupon_invalid
        - coupon_used
        - ticket_closed
        - webhook_failed
        - plugins_unavailable
        - plugins_unsupported
        - bot_not_linked
        - modpack_unsupported
        - modpack_invalid
        - modpack_version_mismatch
        - plugin_checksum_mismatch
        - too_many_attempts
        - second_factor_required
        - oauth_exchange_failed
        - oauth_link_refused
        - passkey_unavailable
        - passkey_failed
        - last_sign_in_method
        - oauth_already_linked
        - demo_resource
        - ssh_key_private
        - ssh_key_weak
        - limit_ssh_keys_reached
        - appearance_unsupported
      example: limit_ram_exceeded

    ValidationField:
      type: object
      description: One failed constraint, as it appears in `details.fields`.
      required: [field, rule]
      properties:
        field:
          type: string
          example: ram_mb
        rule:
          type: string
          description: 'The constraint that failed: `required`, `oneof`, `one_of`, `min`, `max_length`, `gt`, `equals`, `json`, `name_format`, `key_format`, `taken`, `descriptor`, `no_provider`, `pair`, `range`.'
          example: min
        param:
          type: string
          description: The constraint's argument, when it has one.
          example: "128"

    # --- shared enums ----------------------------------------------------

    Kind:
      type: string
      description: The product family. One model and one route tree serve both.
      enum: [game, bot]
      example: game

    Role:
      type: string
      enum: [user, admin]
      example: user

    InstanceStatus:
      type: string
      description: |
        Lifecycle state. The daemon owns the runtime states
        (`installing`/`starting`/`running`/`stopping`/`offline`/`failed`); the
        API owns the billing states (`suspended`/`deleting`).
      enum: [installing, offline, starting, running, stopping, suspended, deleting, failed]
      example: running

    BackupStatus:
      type: string
      enum: [pending, running, completed, failed]
      example: completed

    NodeStatus:
      type: string
      enum: [online, degraded, offline]
      example: online

    TicketStatus:
      type: string
      description: 'Flow: open -> answered -> customer_reply -> closed.'
      enum: [open, answered, customer_reply, closed]
      example: open

    CreditTransactionType:
      type: string
      description: Positive amounts credit the account, negative debit it.
      enum: [starter, daily_bonus, referral, admin_grant, renewal, boost, refund]
      example: daily_bonus

    # --- entities ---------------------------------------------------------

    User:
      type: object
      description: |
        An account. The password hash, the sealed TOTP secret, the referral
        payout stamp and the soft-delete stamp are never serialised.
      required: [id, email, display_name, role, referral_code, created_at]
      properties:
        id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        email:
          type: string
          format: email
          example: lena.hoffmann@example.de
        email_verified_at:
          type: [string, 'null']
          format: date-time
          description: Null until the address is confirmed. Null blocks every endpoint behind `verifiedRequired`.
          example: '2026-08-02T09:14:33Z'
        pending_email:
          type: string
          description: |
            An address that has been asked for and not yet confirmed, empty
            otherwise. `email` above does not move until the new mailbox
            confirms, so a change nobody completes leaves the account exactly
            where it was. Present so the account page can say a change is
            waiting and offer to drop it.
          example: ''
        display_name:
          type: string
          example: Lena Hoffmann
        avatar_url:
          type: string
          example: https://citexnode.de/avatars/lena.png
        locale:
          type: string
          description: BCP 47 tag used for mails and the UI default.
          example: de
        accent:
          $ref: '#/components/schemas/Accent'
        role:
          $ref: '#/components/schemas/Role'
        totp_enabled:
          type: boolean
          description: True only after `POST /auth/totp/confirm` succeeded.
          example: false
        referral_code:
          type: string
          example: LENA-4KQ2
        referred_by:
          type: [string, 'null']
          description: Account id that invited this one.
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        suspended_at:
          type: [string, 'null']
          format: date-time
          example: null
        suspended_reason:
          type: string
          example: ''
        last_daily_claim_at:
          type: [string, 'null']
          format: date-time
          example: '2026-08-18T06:12:00Z'
        last_login_at:
          type: [string, 'null']
          format: date-time
          example: '2026-08-18T05:58:41Z'
        created_at:
          type: string
          format: date-time
          example: '2026-08-01T18:22:07Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-08-18T06:12:00Z'

    Session:
      type: object
      description: |
        One refresh-token lineage. The token hash and the family id are never
        serialised -- the family is an internal detail of replay detection.
      required: [id, user_id, expires_at, created_at]
      properties:
        id:
          type: string
          example: 6d1a2c4e-9f3b-4a17-8c0d-2b5e7a91f4c3
        user_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        user_agent:
          type: string
          example: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/141.0
        ip:
          type: string
          example: 84.132.17.9
        expires_at:
          type: string
          format: date-time
          example: '2026-09-17T05:58:41Z'
        revoked_at:
          type: [string, 'null']
          format: date-time
          example: null
        last_used_at:
          type: string
          format: date-time
          example: '2026-08-18T05:58:41Z'
        created_at:
          type: string
          format: date-time
          example: '2026-08-18T05:58:41Z'
        family_started_at:
          type: string
          format: date-time
          description: |
            When the sign-in behind this session happened, carried unchanged
            through every token rotation. `created_at` moves with each rotation
            and therefore cannot answer how old a sign-in is -- which is what
            the absolute lifetime (`AUTH_SESSION_MAX_LIFETIME`, 90 days) is
            measured against. Reaching it revokes the whole lineage.
          example: '2026-08-18T05:58:41Z'
        device:
          $ref: '#/components/schemas/Device'

    Device:
      type: object
      description: |
        What the user agent says about the machine, derived on read and never
        stored. Every field may be empty: a user agent is a claim, not a fact,
        and an empty field means the string did not say rather than that the
        answer is unknown-but-guessable. Clients fall back to the raw
        `user_agent` when all three are empty.
      properties:
        kind:
          type: string
          enum: ['', phone, tablet, desktop]
          example: desktop
        browser:
          type: string
          example: Firefox
        os:
          type: string
          example: Windows

    Plan:
      type: object
      description: |
        The single source of truth for resource limits. Every creation form and
        every usage meter reads its numbers from here; nothing in the frontend
        hardcodes one, and the API is the only thing that enforces them.
      required: [id, key, kind, name, ram_mb, cpu_percent, disk_mb, max_instances, active, available]
      properties:
        id:
          type: string
          example: 0c5e4a91-3f7d-4b28-8e6a-1d9c2f80b374
        key:
          type: string
          description: Stable identifier used when creating an instance.
          example: free-game
        kind:
          $ref: '#/components/schemas/Kind'
        name:
          type: string
          example: Free Game
        description:
          type: string
          example: Kostenloser Gameserver mit 4 GB RAM und taeglicher Verlaengerung.
        ram_mb:
          type: integer
          example: 4096
        cpu_percent:
          type: integer
          description: 'As cgroups expresses it: 200 means two cores'' worth of time, not two dedicated cores.'
          example: 200
        disk_mb:
          type: integer
          example: 10240
        max_instances:
          type: integer
          description: Instances of this plan per account. A suspended instance still counts.
          example: 1
        backup_slots:
          type: integer
          description: 0 disables backups for the plan entirely.
          example: 1
        database_slots:
          type: integer
          example: 1
        extra_ports:
          type: integer
          example: 1
        renewal_days:
          type: integer
          description: Days a renewal adds to `expires_at`.
          example: 4
        renewal_cost:
          type: integer
          description: Credits one renewal costs.
          example: 20
        grace_days:
          type: integer
          description: Days between suspension and deletion.
          example: 7
        sort_order:
          type: integer
          example: 10
        active:
          type: boolean
          description: Inactive plans stay on `GET /admin/plans` and vanish from `GET /plans`.
          example: true
        available:
          type: boolean
          description: |
            Whether a node could take this plan **right now**.

            It comes from the placement rule itself, not from a second guess at
            it: a page that offers what the order path refuses reads as a broken
            checkout, and the two cannot disagree while there is only one rule.

            A momentary truth and not a reservation. Two people taking the last
            slot is a race the order path settles, the same way it always did --
            this is a shop sign. `false` means sold out, never broken: an
            operator taking a node out of rotation is a normal state.
        created_at:
          type: string
          format: date-time
          example: '2026-07-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-08-10T12:30:00Z'

    EggVersion:
      type: object
      description: |
        One installable version of a template, straight from the software's own
        upstream. Exactly one entry in a list carries `recommended`.
      required: [id, stable, recommended]
      properties:
        id:
          type: string
          description: |
            What the version is called upstream. A Minecraft version for the
            game servers and the mod loaders, the proxy's own version for
            Velocity, and a CI build number for BungeeCord, which publishes no
            releases at all.
          example: '26.2'
        stable:
          type: boolean
          description: |
            False for a snapshot, release candidate or pre-release. The form
            puts these in their own group rather than marking them inline.
          example: true
        java_minimum:
          type: integer
          description: |
            The Java major this version needs at minimum. Absent when the
            upstream publishes none, which a client must read as unknown and
            never as "any Java will do".
          example: 25
        recommended:
          type: boolean
          description: What the creation form preselects.
          example: true

    Egg:
      type: object
      description: |
        A launch template -- Pterodactyl's term, kept because operators know it.
        Adding a game or a bot runtime is a row here, not a code change.
      required: [id, key, kind, name, runtime, image, active]
      properties:
        id:
          type: string
          example: 8d40f26b-91ce-4a53-b7f2-06c8e5a3d114
        key:
          type: string
          example: minecraft-paper
        kind:
          $ref: '#/components/schemas/Kind'
        name:
          type: string
          example: Minecraft (Paper)
        description:
          type: string
          example: Paper 1.21, empfohlen fuer Plugins.
        runtime:
          type: string
          example: minecraft-java
        image:
          type: string
          description: Container or rootfs reference handed to the daemon.
          example: ghcr.io/citexnode/paper:1.21
        images:
          type: object
          additionalProperties:
            type: string
          description: |
            The Java ladder: major version to image. Empty for everything that
            is not a Java server -- a Bedrock image carries no JVM, and a Node
            runtime has one version that matters.

            An instance records which rung it runs on. A rung removed here is a
            rung nobody can pick, and the instances already on it fall back to
            `image` rather than failing to start.
          example:
            '8': eclipse-temurin:8-jre
            '21': eclipse-temurin:21-jre
            '25': eclipse-temurin:25-jre
        java_major:
          type: integer
          description: |
            Which rung of the template's Java ladder this instance runs on. 0 is
            the template's own image, which is what every instance created
            before the ladder existed carries.
          example: 21
        start_cmd:
          type: string
          description: May contain `{{VAR}}` placeholders filled from the environment.
          example: java -Xms128M -Xmx{{RAM}}M -jar paper.jar nogui
        stop_cmd:
          type: string
          description: Graceful stop written to stdin before a kill.
          example: stop
        config_json:
          type: string
          description: JSON stored as text -- variable descriptors and defaults for the creation form.
          example: '{"variables":[{"key":"MOTD","default":"CitexNode"}]}'
        min_ram_mb:
          type: integer
          description: A plan below this cannot run the egg.
          example: 1024
        sort_order:
          type: integer
          example: 20
        active:
          type: boolean
          example: true
        created_at:
          type: string
          format: date-time
          example: '2026-07-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-08-04T08:15:00Z'

    Node:
      type: object
      description: |
        One machine running the daemon. The daemon URL and the sealed daemon
        token are never serialised, so this object is safe to return to an
        operator's browser.
      required: [id, name, status, active]
      properties:
        id:
          type: string
          example: node-fsn1-01
        name:
          type: string
          example: Falkenstein 01
        fqdn:
          type: string
          example: fsn1-01.nodes.citexnode.de
        region:
          type: string
          example: de-fsn
        kind:
          type: string
          description: Which product families may be placed here.
          enum: [game, bot, mixed]
          example: mixed
        max_ram_mb:
          type: integer
          example: 131072
        max_disk_mb:
          type: integer
          example: 2097152
        allocated_ram_mb:
          type: integer
          description: Sum of provisioned RAM on this node. Computed, not stored.
          example: 61440
        allocated_disk_mb:
          type: integer
          description: Sum of provisioned disk on this node. Computed, not stored.
          example: 409600
        port_range_from:
          type: integer
          example: 25000
        port_range_to:
          type: integer
          example: 25999
        status_component_id:
          type: string
          description: |
            The Instatus component this node is published as, or empty when it
            is not published.

            It exists because Instatus cannot measure a node. Its probes reach a
            URL from the internet; a node answers only from the API's address,
            behind an nginx allowlist, and is healthy only when its daemon
            answers and its disk has room. A ping from outside says the box has
            power, which is the one thing nobody was worried about.

            With an id set, the health tick writes the real answer every thirty
            seconds: operational, degraded, or a major outage for a node that is
            down or has gone silent. A component an operator has put under
            maintenance is never overwritten.

            Not a credential, unlike the daemon token beside it, so it is
            returned as well as accepted -- the editor has to render the current
            value in order to change it. Clearing the field stops publishing.
          example: cmt5wmslw11xz0kplsxyopkw9
        status:
          $ref: '#/components/schemas/NodeStatus'
        last_seen_at:
          type: [string, 'null']
          format: date-time
          description: Refreshed by a usage push or a heartbeat; ages into `offline`.
          example: '2026-08-18T07:59:12Z'
        active:
          type: boolean
          description: False drains the node -- no new placements, existing instances keep running.
          example: true
        created_at:
          type: string
          format: date-time
          example: '2026-07-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-08-18T07:59:12Z'

    Instance:
      type: object
      description: |
        A game server or a hosted bot. One table for both, because the lifecycle
        (create, renew, suspend, delete) is identical and `kind` selects the plan
        family and which endpoints apply. The daemon reference and the
        soft-delete stamp are never serialised.
      required: [id, user_id, plan_id, egg_id, node_id, name, kind, status, ram_mb, cpu_percent, disk_mb, expires_at]
      properties:
        id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        user_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        plan_id:
          type: string
          example: 0c5e4a91-3f7d-4b28-8e6a-1d9c2f80b374
        egg_id:
          type: string
          example: 8d40f26b-91ce-4a53-b7f2-06c8e5a3d114
        node_id:
          type: string
          example: node-fsn1-01
        name:
          type: string
          description: 3-32 characters from letters, digits, space, underscore, dot or hyphen.
          example: Bergmanns Survival
        kind:
          $ref: '#/components/schemas/Kind'
        status:
          $ref: '#/components/schemas/InstanceStatus'
        ram_mb:
          type: integer
          description: Provisioned RAM, before any boost.
          example: 4096
        cpu_percent:
          type: integer
          example: 200
        disk_mb:
          type: integer
          example: 10240
        primary_port:
          type: integer
          description: The port players connect to. 0 for bots.
          example: 25031
        start_cmd:
          type: string
          example: java -Xms128M -Xmx4096M -jar paper.jar nogui
        java_major:
          type: integer
          description: |
            Which rung of the template's Java ladder to start on. Absent is the
            template's own image. A rung the template does not offer is refused
            (`java_major` / `unknown_image`) before anything is provisioned.
          example: 21
        git_repo:
          type: string
          description: Bots only -- optional source repository cloned on deploy.
          example: https://github.com/lenah/discord-bot
        expires_at:
          type: string
          format: date-time
          description: Renewal deadline. Passing it suspends the instance.
          example: '2026-08-22T18:00:00Z'
        suspended_at:
          type: [string, 'null']
          format: date-time
          example: null
        suspended_reason:
          type: string
          description: |
            Why the instance is stopped. Empty means the one reason that existed
            before this field did: a renewal that never came.
          example: ''
        suspend_kind:
          type: string
          description: |
            `hold` is a suspension with no deletion countdown -- the instance
            waits for something its owner can fix, currently an unconfirmed
            address. Empty is the billing suspension, which does count down.
          enum: ['', hold]
          example: ''
        delete_after:
          type: [string, 'null']
          format: date-time
          description: |
            Set on a billing suspension to now plus the plan's grace days.
            Deletion is scheduled, never immediate. Null on a hold: confirming
            an address is not something to put a deadline on.
          example: null
        last_start_at:
          type: [string, 'null']
          format: date-time
          example: '2026-08-18T05:02:11Z'
        restart_on_crash:
          type: boolean
          description: |
            Whether the platform starts this instance again after it goes down
            on its own. Off by default and for every instance that existed
            before the column did.

            A crash is "running straight to offline or failed" -- every
            deliberate stop writes the stored status first, so it cannot be
            mistaken for one. The restart is refused when the instance is
            suspended, and when it had been up for less than two minutes: a
            server that dies in the first half-minute is one that cannot start,
            and restarting that is a loop rather than a recovery.
          example: false
        created_at:
          type: string
          format: date-time
          example: '2026-08-02T11:41:55Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-08-18T05:02:11Z'
        plan:
          $ref: '#/components/schemas/Plan'
        egg:
          $ref: '#/components/schemas/Egg'
        node:
          $ref: '#/components/schemas/Node'

    InstancePort:
      type: object
      required: [id, instance_id, port, is_primary]
      properties:
        id:
          type: string
          example: c04b7e19-6a3f-4d52-91c8-2e70b5d8a63f
        instance_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        port:
          type: integer
          example: 25031
        is_primary:
          type: boolean
          description: Exactly one port per instance is primary; the extras count against the plan's `extra_ports`.
          example: true
        note:
          type: string
          example: Voice
        created_at:
          type: string
          format: date-time
          example: '2026-08-02T11:41:55Z'

    Boost:
      type: object
      description: Temporary additive RAM. Expires on its own; the scheduler pushes the base limit back down.
      required: [id, instance_id, user_id, type, amount, cost, expires_at]
      properties:
        id:
          type: string
          example: e5a2c78b-3f14-4d69-b027-9c8e1a4f5d30
        instance_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        user_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        type:
          type: string
          enum: [ram]
          example: ram
        amount:
          type: integer
          description: Megabytes added on top of the provisioned RAM.
          example: 2048
        cost:
          type: integer
          description: Credits actually charged, recorded at purchase time.
          example: 40
        expires_at:
          type: string
          format: date-time
          example: '2026-08-21T14:30:00Z'
        created_at:
          type: string
          format: date-time
          example: '2026-08-18T14:30:00Z'

    DomainPriceRule:
      type: object
      required: [percent_markup, add_cents, autosync]
      properties:
        percent_markup: { type: integer }
        add_cents: { type: integer }
        autosync: { type: boolean }
        last_sync_at: { type: string, format: date-time, nullable: true }
        last_result:
          type: object
          nullable: true
          properties:
            total: { type: integer }
            added: { type: integer }
            kept: { type: integer }
            updated: { type: integer }
            skipped: { type: integer }
            promos: { type: integer }

    DomainPrice:
      type: object
      description: |
        What one extension costs a customer, in credits. 100 credits is 1.00
        EUR; one credit is one cent, and nothing in the paying path rounds.

        A registrar promotion on the first year shows as `promo_until` (last
        day, `YYYY-MM-DD`) with `promo_regular_credits` (the register price
        outside the promotion); `register_credits` already carries the
        promotional price while it runs. Empty and zero otherwise.
      required: [tld, register_credits, renew_credits, transfer_credits, min_period, max_period, active]
      properties:
        promo_until:
          type: string
          example: "2026-12-31"
        promo_regular_credits:
          type: integer
          example: 2399
        tld:
          type: string
          description: Without a leading dot.
          example: de
        register_credits:
          type: integer
          example: 799
        renew_credits:
          type: integer
          description: |
            Per year, and frequently higher than registration -- the first year
            is subsidised by the registry, the rest is not.
          example: 699
        transfer_credits:
          type: integer
          example: 699
        min_period:
          type: integer
          description: Years. The registrar's own floor.
          example: 1
        max_period:
          type: integer
          description: Years. 1 for `.de`, which sells no longer term.
          example: 1
        active:
          type: boolean
          example: true

    DNSRecord:
      type: object
      description: One record, relative to its zone, in zone-file text form.
      required: [name, type, content]
      properties:
        name:
          type: string
          description: Relative to the zone; `@` is the apex. `www`, `_dmarc`, `*`.
          example: www
        type:
          type: string
          enum: [A, AAAA, CNAME, ALIAS, MX, SRV, TXT, CAA, NS, PTR, TLSA]
        ttl:
          type: integer
          description: Seconds, 60 to 86400. Omitted or 0 means 3600.
          example: 3600
        content:
          type: string
          description: |
            The record's text. `94.249.187.52`, `10 mail.example.de`,
            `v=spf1 mx -all`, `0 issue letsencrypt.org`. Hostnames may be
            written with or without the trailing dot; TXT with or without quotes.
          example: 94.249.187.52

    DNSZone:
      type: object
      description: A domain's zone on the platform's nameservers, minus SOA and apex NS.
      required: [name, exists, nameservers, records]
      properties:
        name:
          type: string
          example: meinserver.de
        exists:
          type: boolean
          description: False until the first write created the zone.
        nameservers:
          type: array
          items:
            type: string
          example: [ns1.citexnode.de, ns2.citexnode.de]
        records:
          type: array
          items:
            $ref: '#/components/schemas/DNSRecord'

    Domain:
      type: object
      description: |
        One registration this platform holds for a customer. The registrar is
        the source of truth for status and expiry; this is the platform's copy
        of it, plus the two things Openprovider cannot know -- which account owns
        it, and whether it is real.
      required: [id, user_id, name, tld, status, autorenew, demo]
      properties:
        id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        name:
          type: string
          description: The full lowercase name.
          example: meinserver.de
        tld:
          type: string
          description: |
            Carried separately because the price hangs off it and cutting at the
            first dot gets `co.uk` wrong.
          example: de
        status:
          type: string
          description: |
            The registrar's own word -- `active`, `pending`, `expired`. Not
            translated into a vocabulary of this platform's own, because two
            vocabularies for one state is a mapping that eventually disagrees
            with itself.
          example: active
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Null while a registration is still pending. A zero date rendered as
            a date is a lie with a year in it.
        autorenew:
          type: boolean
          example: true
        nameservers:
          type: string
          description: Comma-separated, in the registrar's own order.
          example: ns1.citexnode.de,ns2.citexnode.de
        demo:
          type: boolean
          description: |
            A walkthrough row with nothing behind it at the registrar. Every
            write that would spend money refuses it with `demo_resource`. Shown
            rather than hidden: a row that passes itself off as real is worse
            than a badge that says what it is.
          example: false
        whois_privacy:
          type: boolean
          description: The registrant's address is hidden from public WHOIS (registrar-side; copied here).
        transfer_lock:
          type: boolean
          description: Transfers away are refused by the registry until this is off.
        dnssec:
          type: boolean
          description: The zone on ns1/ns2 is signed.
        forward_target:
          type: string
          description: HTTP redirect target; empty is off. See `/domains/{id}/forwarding`.
        forward_code:
          type: integer
          enum: [301, 302]
        notify_expiry:
          type: boolean
          description: Mail 30 and 7 days before the registration runs out.
        notify_changes:
          type: boolean
          description: Mail on every registrar-side change (nameservers, DNSSEC, settings).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    DomainSettings:
      type: object
      description: |
        The switches of the settings area. Every field is optional: absent means
        unchanged, so a client can flip one without restating the rest.
      properties:
        autorenew: { type: boolean }
        whois_privacy: { type: boolean }
        transfer_lock: { type: boolean }
        notify_expiry: { type: boolean }
        notify_changes: { type: boolean }

    DNSSECState:
      type: object
      required: [enabled, ds, registrar_synced]
      properties:
        enabled:
          type: boolean
          description: The zone on ns1 carries an active signing key.
        ds:
          type: array
          items: { type: string }
          description: The DS records the registry holds (or should), one line each.
          example: ["12345 13 2 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"]
        registrar_synced:
          type: boolean
          description: |
            False while the zone is signed but the keys are not published at the
            registry: no registrar configured, or it refused. The chain of trust
            is only complete once this is true.

    Forwarding:
      type: object
      required: [enabled, target, code, host, available]
      properties:
        enabled: { type: boolean }
        target:
          type: string
          description: Absolute http(s) URL; empty when off.
          example: https://example.de/willkommen
        code:
          type: integer
          enum: [301, 302]
        host:
          type: string
          description: The address the apex and `www` A records point at while forwarding is on.
          example: 77.90.63.73
        available:
          type: boolean
          description: False when no forwarding host is configured; the write then answers `dns_unavailable`.

    ResourcePermission:
      type: object
      description: |
        One right on one resource, and what it costs if it is granted wrongly.

        Not `Permission`: that is the operator surface's own vocabulary, where
        `instances.danger` deletes anybody's server. This one is a customer
        sharing something they own, where `gameserver.delete` deletes that one. The level is
        derived from the key by one rule rather than stored per row: `.view`,
        `.download` and `.export` read; `.delete`, `.reset`, `.reinstall`,
        `.restore`, anything ending in `.permissions` and `billing.cancel` are
        dangerous; everything else writes. A key nobody thought about lands in
        `write`, never in `read`.
      required: [key, level]
      properties:
        key:
          type: string
          example: gameserver.backups.delete
        level:
          type: string
          enum: [read, write, dangerous]
          example: dangerous

    PermissionGroup:
      type: object
      description: |
        One heading and the rights under it. The grouping is part of the
        catalogue rather than the client's business.
      required: [key, permissions]
      properties:
        key:
          type: string
          description: Translated in the client. `console`, `backups`, `team` and so on.
          example: backups
        permissions:
          type: array
          items:
            $ref: '#/components/schemas/ResourcePermission'

    RolePreset:
      type: object
      description: |
        A starting point somebody picks and then narrows. What gets stored is
        always the permission list; the preset is what filled it in, which is
        why a Developer without `backups.delete` needs no new role.
      required: [key, wildcard]
      properties:
        key:
          type: string
          enum: [owner, admin, manager, developer, moderator, viewer]
          example: developer
        wildcard:
          type: boolean
          description: |
            Owner only. Everything, including rights added in a later release --
            which is the difference between "the owner" and "somebody granted
            every right last Tuesday".
          example: false
        grants:
          type: array
          description: Empty when `wildcard` is set.
          items:
            type: string

    InstanceSSHKey:
      type: object
      description: |
        One public key that opens an instance's SFTP directory.

        Per instance and not per account, because the SFTP username *is* the
        instance: a key opens exactly the directory it was filed against.
        Somebody with two servers files the same key twice, which is the honest
        shape — a key that silently covered the next server as well would be a
        widening of access nobody asked for.

        The key itself is never sent back.
      required: [id, instance_id, name, fingerprint, created_at]
      properties:
        id:
          type: string
          format: uuid
        instance_id:
          type: string
          format: uuid
        name:
          type: string
          example: Laptop
        fingerprint:
          type: string
          description: The same `SHA256:…` the customer's own client prints.
          example: 'SHA256:1Xy8p2vJ0oQ7rM4nB6cD9eF3gH5iJ7kL9mN1oP3qR5s'
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Null means never. A key that has not opened a session in a year is
            the first one somebody should remove.

    KVMMachine:
      type: object
      description: |
        One virtual server. Its own model rather than a third instance kind: an
        instance has an egg, a node and a daemon reference, and a virtual
        machine has none of the three.

        There is no field pointing at a hypervisor because there is no
        hypervisor. Status is stored rather than asked.
      required: [id, user_id, name, series, vcores, ram_gb, disk_gb, price_credits, status, demo]
      properties:
        id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        name:
          type: string
          example: Ryzen 8G Demo
        series:
          type: string
          description: One of the three lines the KVM page sells.
          enum: [ryzen, xeon, epyc]
          example: ryzen
        vcores:
          type: integer
          example: 4
        ram_gb:
          type: integer
          description: GB, like the configurator. Two units for one number is a conversion somebody forgets.
          example: 8
        disk_gb:
          type: integer
          example: 100
        ipv4:
          type: integer
          description: How many addresses, not which. One is included in the base price.
          example: 1
        ipv6:
          type: integer
          example: 1
        price_credits:
          type: integer
          description: |
            Per month, as it stood when the machine was ordered. 100 credits is
            1.00 EUR.
          example: 1500
        status:
          type: string
          enum: [running, stopped]
          example: running
        ipv4_address:
          type: string
          description: Empty while no machine is assigned.
          example: 192.0.2.10
        ipv6_address:
          type: string
          example: 2001:db8::10
        hostname:
          type: string
          example: ''
        renews_at:
          type: string
          format: date-time
          nullable: true
        demo:
          type: boolean
          description: |
            A walkthrough row. There is no machine behind it, and there is no
            hypervisor for one to live on.
          example: false
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    DomainOffer:
      allOf:
        - $ref: '#/components/schemas/DomainPrice'
        - type: object
          required: [domain, status, available]
          properties:
            domain:
              type: string
              example: meinserver.de
            status:
              type: string
              description: |
                The registrar's own word. `free` is the only one that can be
                bought; the rest are reasons it cannot.
              example: free
            reason:
              type: string
              example: ''
            is_premium:
              type: boolean
              description: |
                A name its registry prices individually, for which the table's
                price does not apply. Never offered.
              example: false
            available:
              type: boolean
              description: Free **and** not premium. The only flag a client needs.
              example: true

    DomainMargin:
      allOf:
        - $ref: '#/components/schemas/DomainPrice'
        - type: object
          required: [known, loss]
          properties:
            known:
              type: boolean
              description: |
                False when the registrar did not answer for this extension, in
                which case the cost and margin below are not a claim.
              example: true
            cost_register_eur:
              type: number
              description: Euros, from the registrar, live.
              example: 4.65
            cost_renew_eur:
              type: number
              example: 4.65
            margin_register_eur:
              type: number
              example: 3.34
            margin_renew_eur:
              type: number
              example: 2.34
            loss:
              type: boolean
              description: Either side at or below zero. Sorts first.
              example: false

    DomainPriceRequest:
      type: object
      required: [register_credits, renew_credits, transfer_credits, min_period, max_period]
      properties:
        register_credits:
          type: integer
          example: 799
        renew_credits:
          type: integer
          example: 699
        transfer_credits:
          type: integer
          example: 699
        min_period:
          type: integer
          example: 1
        max_period:
          type: integer
          example: 1
        active:
          type: boolean
          example: true

    Backup:
      type: object
      description: One archive of an instance's data directory. The daemon reference is never serialised.
      required: [id, instance_id, name, status, created_at]
      properties:
        id:
          type: string
          example: a17c6e93-4b2d-49f0-85ae-c3d1078f6b52
        instance_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        name:
          type: string
          example: Vor dem Plugin-Update
        size_bytes:
          type: integer
          format: int64
          description: 0 until the node finishes writing the archive.
          example: 734003200
        status:
          $ref: '#/components/schemas/BackupStatus'
        error:
          type: string
          description: Why it failed, when `status` is `failed`.
          example: ''
        created_at:
          type: string
          format: date-time
          example: '2026-08-17T22:00:00Z'
        completed_at:
          type: [string, 'null']
          format: date-time
          example: '2026-08-17T22:03:41Z'

    ManagedDatabase:
      type: object
      description: A MySQL schema attached to an instance. The password is sealed and only served through `DatabaseView`.
      required: [id, instance_id, user_id, name, username, host, port]
      properties:
        id:
          type: string
          example: 7c2f9b18-5e44-4a0d-9f31-6ab8c0d21e57
        instance_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        user_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        name:
          type: string
          description: Generated, not taken from the request -- two users may both want "main".
          example: cx_b3f1a7d5_1
        username:
          type: string
          example: cx_b3f1a7d5_1
        host:
          type: string
          example: db.fsn1.citexnode.de
        port:
          type: integer
          example: 3306
        created_at:
          type: string
          format: date-time
          example: '2026-08-05T16:20:09Z'

    CreditTransaction:
      type: object
      description: |
        One append-only ledger row. There is no `UPDATE` path and no balance
        column on the account: the balance is derived from these rows and cached,
        and `balance_after` lets a single row explain itself in the UI and a
        poisoned cache be detected against the newest row.
      required: [id, user_id, amount, balance_after, type, created_at]
      properties:
        id:
          type: string
          example: 3b6f0a29-8d17-4c54-9e2b-1a7c5d03f846
        user_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        amount:
          type: integer
          description: Signed. Positive credits the account, negative debits it.
          example: -20
        balance_after:
          type: integer
          example: 118
        type:
          $ref: '#/components/schemas/CreditTransactionType'
        reference_type:
          type: string
          description: What the movement paid for.
          example: instance
        reference_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        note:
          type: string
          description: Shown to the user in the ledger.
          example: 'Verlaengerung: Bergmanns Survival'
        created_at:
          type: string
          format: date-time
          example: '2026-08-18T09:03:22Z'

    Ticket:
      type: object
      required: [id, user_id, subject, category, priority, status, created_at]
      properties:
        id:
          type: string
          example: 5e9d3c02-14ab-4f78-83c6-0a2e6b1d94f7
        user_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        instance_id:
          type: [string, 'null']
          description: The instance the thread is about, verified to belong to the author.
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        subject:
          type: string
          example: Server startet nach Plugin-Update nicht
        category:
          type: string
          enum: [general, server, bot, billing, abuse]
          example: server
        priority:
          type: string
          enum: [low, normal, high]
          example: normal
        status:
          $ref: '#/components/schemas/TicketStatus'
        last_reply_at:
          type: string
          format: date-time
          description: What the inbox sorts on.
          example: '2026-08-18T10:12:44Z'
        closed_at:
          type: [string, 'null']
          format: date-time
          example: null
        created_at:
          type: string
          format: date-time
          example: '2026-08-18T09:47:02Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-08-18T10:12:44Z'
        user:
          $ref: '#/components/schemas/User'
        messages:
          type: array
          description: Present on the staff inbox listing; the thread endpoint always fills it.
          items:
            $ref: '#/components/schemas/TicketMessage'

    TicketMessage:
      type: object
      required: [id, ticket_id, user_id, body, is_staff, created_at]
      properties:
        id:
          type: string
          example: 41b8e0d2-77c3-4c9a-9e15-3f6d0a2b8c44
        ticket_id:
          type: string
          example: 5e9d3c02-14ab-4f78-83c6-0a2e6b1d94f7
        user_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        body:
          type: string
          example: Der Server bleibt nach dem Update im Status "starting" haengen.
        is_staff:
          type: boolean
          description: Derived from the author's role at write time, never from the request body.
          example: false
        created_at:
          type: string
          format: date-time
          example: '2026-08-18T09:47:02Z'
        author:
          $ref: '#/components/schemas/User'
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/TicketAttachment'

    TicketAttachment:
      type: object
      description: The store path is never serialised; the file is only reachable through the download endpoint.
      required: [id, message_id, filename, mime_type, size_bytes, created_at]
      properties:
        id:
          type: string
          example: 9a3c1f57-2b60-4d8e-b1a4-05c7e93f2d10
        message_id:
          type: string
          example: 41b8e0d2-77c3-4c9a-9e15-3f6d0a2b8c44
        filename:
          type: string
          example: latest.log
        mime_type:
          type: string
          description: From the allowlist checked on upload.
          enum: [image/png, image/jpeg, image/webp, image/gif, text/plain, text/log, application/json, application/gzip, application/zip]
          example: text/plain
        size_bytes:
          type: integer
          format: int64
          example: 184320
        created_at:
          type: string
          format: date-time
          example: '2026-08-18T09:47:02Z'

    UsageBucket:
      type: object
      description: |
        Aggregated history. Live values never touch the database; only completed
        buckets land here, and old rows are pruned so the SQLite file stays small
        enough to back up by copying. The primary key is the natural triple
        (instance, resolution, bucket start).
      required: [instance_id, resolution, bucket_start]
      properties:
        instance_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        resolution:
          type: string
          enum: [5m, 1h]
          description: '`5m` buckets feed the 24h chart (pruned after 30h), `1h` the 7d chart.'
          example: 5m
        bucket_start:
          type: string
          format: date-time
          example: '2026-08-18T09:45:00Z'
        cpu_avg:
          type: number
          format: double
          example: 47.3
        cpu_peak:
          type: number
          format: double
          example: 112.8
        ram_avg_mb:
          type: number
          format: double
          example: 2810.4
        ram_peak_mb:
          type: number
          format: double
          example: 3240.9
        disk_mb:
          type: number
          format: double
          example: 6142.0
        net_rx_bytes:
          type: integer
          format: int64
          example: 18452301
        net_tx_bytes:
          type: integer
          format: int64
          example: 9123774
        samples:
          type: integer
          description: How many daemon reports the bucket averages. A low count means a gap.
          example: 30

    AuditLog:
      type: object
      description: One operator action. Append-only -- no endpoint edits or deletes a row.
      required: [id, actor_id, action, created_at]
      properties:
        id:
          type: string
          example: f1c78d34-2a95-4e08-b6d1-4c930e58a712
        actor_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        actor_email:
          type: string
          description: Re-read from the database at action time, not taken from the token.
          example: jonas.bergmann@citexnode.de
        action:
          type: string
          example: admin.user_suspend
        target_type:
          type: string
          example: user
        target_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        meta_json:
          type: string
          description: JSON stored as text, carrying whatever the action needed to record.
          example: '{"reason":"Zahlungsbetrug"}'
        ip:
          type: string
          example: 84.132.17.9
        created_at:
          type: string
          format: date-time
          example: '2026-08-18T11:05:19Z'

    # --- service views ----------------------------------------------------

    InstanceView:
      allOf:
        - $ref: '#/components/schemas/Instance'
        - type: object
          description: |
            The instance plus the limits it is measured against and where it
            currently sits inside them -- everything the detail page needs in one
            call.
          required: [ports, boosts, effective_ram_mb, renewal_cost, renewal_days, can_renew, address]
          properties:
            ports:
              type: array
              items:
                $ref: '#/components/schemas/InstancePort'
            boosts:
              type: array
              description: Live boosts only; expired ones are gone.
              items:
                $ref: '#/components/schemas/Boost'
            effective_ram_mb:
              type: integer
              description: Provisioned RAM plus every live boost. This is what the daemon actually enforces.
              example: 6144
            usage:
              oneOf:
                - $ref: '#/components/schemas/Usage'
                - type: 'null'
              description: The newest sample the node reported, or null when nothing has been reported yet.
            renewal_cost:
              type: integer
              description: Credits the next renewal costs, read from the plan.
              example: 20
            renewal_days:
              type: integer
              example: 4
            max_upload_bytes:
              type: integer
              format: int64
              description: |
                What the file manager may upload to this instance in one
                request, from `LIMIT_INSTANCE_UPLOAD_BYTES`. Answered rather
                than mirrored: the frontend used to carry its own copy of this
                number and went on refusing files the server had been raised to
                accept.
              example: 1073741824
            can_renew:
              type: boolean
              description: Whether the balance covers `renewal_cost`. Renewing works while suspended.
              example: true
            address:
              type: string
              description: What a player connects to. Empty for bots.
              example: fsn1-01.nodes.citexnode.de:25031

    Profile:
      allOf:
        - $ref: '#/components/schemas/User'
        - type: object
          description: The account plus the three counts the dashboard header would otherwise fetch separately.
          required: [credits, open_tickets, instances]
          properties:
            credits:
              type: integer
              description: Balance derived from the ledger.
              example: 138
            open_tickets:
              type: integer
              format: int64
              example: 1
            instances:
              type: integer
              format: int64
              example: 2
            recovery_codes_left:
              type: integer
              description: |
                How many unused recovery codes the account still has. A count,
                never the codes -- they are readable only in the response that
                created them. Zero on an account without a second factor, and
                zero on one that has burned through its batch, which is the
                number a settings page turns into "mint a new set while you
                still can".
              example: 8
            sign_in:
              $ref: '#/components/schemas/SignInMethods'

    SignInMethods:
      type: object
      description: |
        Every way this account can get back in, in one place. Carried on the
        profile rather than fetched separately: the account page draws all of it
        in one section, and a second round trip would be a second thing to keep
        in step.

        `has_password` is a boolean and never the hash. It exists because an
        account created through a provider has none at all, and the page has to
        say so -- otherwise the only hint is a password form that refuses every
        attempt.

        Whether the deployment can do passkeys at all is on `PublicPlatform`,
        not here: the login page has to know it too and belongs to nobody yet.
      required: [has_password]
      properties:
        has_password: { type: boolean }
        providers:
          type: array
          description: One entry per connected provider account.
          items:
            type: object
            required: [id, user_id, provider, created_at]
            properties:
              id: { type: string, example: 9c4f1d20-3b7a-4e58-a1c6-0d2f83e7b915 }
              user_id: { type: string, example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261 }
              provider: { type: string, enum: [google, apple, discord] }
              email:
                type: string
                description: |
                  What the provider said the address was, so somebody can see
                  which Google account this is. Never used to resolve identity --
                  that is the provider's subject, which is never serialised.
                example: name@gmail.com
              created_at: { type: string, format: date-time }
              last_used_at: { type: [string, 'null'], format: date-time }
        passkeys:
          type: array
          items:
            $ref: '#/components/schemas/Passkey'

    Passkey:
      type: object
      description: |
        One WebAuthn credential: one device, one key pair, one row.

        Only the public half exists here and none of it is serialised -- the
        credential id, the public key, the signature counter and the AAGUID are
        stored but never returned, because nothing a client does needs them and
        an id in a response is an id in a log.
      required: [id, user_id, created_at]
      properties:
        id:
          type: string
          example: 4b7e2f10-8c3d-4a95-b0e1-6f52a8d7c934
        user_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        name:
          type: string
          description: What the owner called it. Filled in from the authenticator when they named nothing, because a list of blank rows is one nobody can safely delete from.
          example: iPhone
        transports:
          type: string
          description: Comma-separated (usb, nfc, ble, internal, hybrid), handed back to the browser as a hint about where to look next time.
          example: internal,hybrid
        backed_up:
          type: boolean
          description: |
            The key lives in a synchronising keychain, so it survives a lost
            phone. Shown because it is the difference between "you have a spare"
            and "this is the only copy".
          example: true
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, 'null'], format: date-time }

    PasskeyChallenge:
      type: object
      description: |
        What the browser passes to `navigator.credentials`. `handle` names the
        challenge the API is holding; it comes back on the finish call, because
        the challenge must not be something the client chooses -- that is the
        whole anti-replay mechanism.
      required: [handle, options]
      properties:
        handle:
          type: string
          example: 7c1f0b52-4d38-4a06-9e75-c3b8a1d6f402
        options:
          type: object
          additionalProperties: true
          description: The WebAuthn options object, exactly as the library produced it. Passed through untouched; its shape is the specification's, not this API's.

    PasskeyFinishRequest:
      type: object
      required: [handle, credential]
      properties:
        handle:
          type: string
          description: The handle from the matching begin call.
          example: 7c1f0b52-4d38-4a06-9e75-c3b8a1d6f402
        name:
          type: string
          description: Registration only, and optional. Blank is filled in from the authenticator.
          example: iPhone
        credential:
          type: object
          additionalProperties: true
          description: The browser's answer, forwarded untouched.

    TicketThread:
      allOf:
        - $ref: '#/components/schemas/Ticket'
        - type: object
          required: [messages]
          properties:
            messages:
              type: array
              description: Every message, oldest first, each with its author and attachments.
              items:
                $ref: '#/components/schemas/TicketMessage'

    DatabaseView:
      allOf:
        - $ref: '#/components/schemas/ManagedDatabase'
        - type: object
          description: |
            The database with its password decrypted. The only stored secret this
            API ever serves back in plaintext, and only to the owner: a database
            is useless without it.
          required: [password]
          properties:
            password:
              type: string
              example: 8Qz-vT4mHc1LrKp9
    UserDetail:
      allOf:
        - $ref: '#/components/schemas/User'
        - type: object
          description: One account with everything an operator needs to answer a support question without opening five tabs.
          required: [credits, instances, recent_transactions]
          properties:
            credits:
              type: integer
              example: 138
            instances:
              type: array
              items:
                $ref: '#/components/schemas/Instance'
            recent_transactions:
              type: array
              items:
                $ref: '#/components/schemas/CreditTransaction'

    EnvEntry:
      type: object
      description: |
        One environment variable. `value` is omitted entirely when `secret` is
        true -- the UI shows a masked placeholder and can only overwrite it.
      required: [key, secret]
      properties:
        key:
          type: string
          example: MOTD
        value:
          type: string
          description: Absent for secret variables.
          example: CitexNode Testserver
        secret:
          type: boolean
          example: false

    TokenPair:
      type: object
      required: [access_token, refresh_token, expires_at, token_type]
      properties:
        access_token:
          type: string
          description: 'Send as `Authorization: Bearer <token>`. Stateless and short-lived.'
          example: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyZjhiOTFjNCJ9.q1w2e3
        refresh_token:
          type: string
          description: Rotated on every use. Replaying an old one revokes the whole family.
          example: rt_9f3c1a7b52d84e60b8a4c7e0d1f52639
        expires_at:
          type: string
          format: date-time
          description: Expiry of the **access** token.
          example: '2026-08-18T12:15:00Z'
        token_type:
          type: string
          enum: [Bearer]
          example: Bearer

    TOTPSetup:
      type: object
      description: Enrolment material. Not active until `POST /auth/totp/confirm` succeeds.
      required: [secret, uri]
      properties:
        secret:
          type: string
          description: Base32 secret, for manual entry.
          example: JBSWY3DPEHPK3PXP
        uri:
          type: string
          description: Render as a QR code for the authenticator app.
          example: otpauth://totp/CitexNode:lena.hoffmann@example.de?secret=JBSWY3DPEHPK3PXP&issuer=CitexNode
    DailyStatus:
      type: object
      description: Whether the daily bonus can be claimed right now, and when otherwise.
      required: [available, amount]
      properties:
        available:
          type: boolean
          example: false
        amount:
          type: integer
          description: Credits the claim would pay.
          example: 10
        next_claim_at:
          type: [string, 'null']
          format: date-time
          description: Null when a claim is available now.
          example: '2026-08-19T06:12:00Z'

    ReferralInfo:
      type: object
      required: [code, link, invited, earned, bonus_per_referral]
      properties:
        code:
          type: string
          example: LENA-4KQ2
        link:
          type: string
          description: Ready-made share link built from the configured web URL.
          example: https://citexnode.de/register?ref=LENA-4KQ2
        invited:
          type: integer
          format: int64
          description: Accounts that signed up with this code.
          example: 4
        earned:
          type: integer
          description: Credits actually paid out. Lower than `invited` x bonus until the invitees verify and create an instance.
          example: 100
        bonus_per_referral:
          type: integer
          example: 50

    CreditConfig:
      type: object
      description: The whole credit economy, admin-editable. Seeded values shown as examples.
      required: [starter_credits, daily_bonus, daily_cooldown_hours, referral_bonus, boost_ram_mb, boost_ram_days, boost_ram_cost, max_boosts_per_instance]
      properties:
        starter_credits:
          type: integer
          description: Granted once at registration.
          example: 100
        daily_bonus:
          type: integer
          example: 10
        daily_cooldown_hours:
          type: integer
          example: 24
        referral_bonus:
          type: integer
          description: Paid when the invited account verifies **and** creates an instance.
          example: 50
        boost_ram_mb:
          type: integer
          example: 2048
        boost_ram_days:
          type: integer
          example: 3
        boost_ram_cost:
          type: integer
          example: 40
        max_boosts_per_instance:
          type: integer
          description: Concurrent live boosts allowed on one instance.
          example: 2

    NodeHealth:
      type: object
      description: |
        A node as the health cache sees it, which is what an operator and the
        status page need -- a row read straight from the table cannot say whether
        the machine is answering.
      required: [id, name, status, checked_at]
      properties:
        id:
          type: string
          example: node-fsn1-01
        name:
          type: string
          example: Falkenstein 01
        region:
          type: string
          example: de-fsn
        kind:
          type: string
          enum: [game, bot, mixed]
          example: mixed
        status:
          $ref: '#/components/schemas/NodeStatus'
        instances:
          type: integer
          example: 42
        running_instances:
          type: integer
          example: 31
        allocated_ram_mb:
          type: integer
          description: Sum of what the placed instances are allowed to use.
          example: 61440
        total_ram_mb:
          type: integer
          example: 131072
        used_ram_mb:
          type: number
          format: double
          description: What they are actually using, summed from the live samples.
          example: 38914.5
        allocated_disk_mb:
          type: integer
          example: 409600
        total_disk_mb:
          type: integer
          example: 2097152
        load_avg:
          type: array
          description: 1, 5 and 15 minute load averages reported by the node.
          items:
            type: number
            format: double
          example: [2.14, 1.87, 1.62]
        checked_at:
          type: string
          format: date-time
          example: '2026-08-18T11:59:40Z'
        error:
          type: string
          description: Why the last check failed, when it did.
          example: 'dial tcp: i/o timeout'

    Summary:
      type: object
      description: The platform verdict served by `GET /status`, aggregated from the node health cache.
      required: [status, nodes_online, nodes_total, checked_at]
      properties:
        status:
          type: string
          enum: [operational, degraded, outage]
          example: operational
        nodes_online:
          type: integer
          description: Nodes answering their health check, degraded ones included.
          example: 3
        nodes_total:
          type: integer
          example: 3
        instances:
          type: integer
          example: 128
        running_instances:
          type: integer
          example: 97
        nodes:
          type: array
          items:
            $ref: '#/components/schemas/NodeHealth'
        checked_at:
          type: string
          format: date-time
          description: How fresh the snapshot is. It is a cache, not a live probe.
          example: '2026-08-18T11:59:40Z'

    Overview:
      type: object
      description: The admin landing page -- the numbers that say whether today is normal.
      required: [users, instances_by_status, tickets_by_status, generated_at]
      properties:
        users:
          type: integer
          format: int64
          example: 214
        instances_by_status:
          type: object
          additionalProperties:
            type: integer
            format: int64
          description: Counts keyed by lifecycle status, deleted ones excluded.
          example:
            running: 97
            offline: 24
            suspended: 7
        tickets_by_status:
          type: object
          additionalProperties:
            type: integer
            format: int64
          example:
            open: 3
            customer_reply: 1
            closed: 88
        credits_issued:
          type: integer
          example: 41250
        credits_spent:
          type: integer
          example: 28640
        credits_spent_24h:
          type: integer
          example: 620
        nodes:
          type: array
          items:
            $ref: '#/components/schemas/NodeHealth'
        instances_reporting:
          type: integer
          description: Instances that pushed a usage sample recently enough to count as live. A gap against `running` means a daemon is quiet.
          example: 96
        generated_at:
          type: string
          format: date-time
          example: '2026-08-18T12:00:03Z'

    # --- realtime and daemon payloads -------------------------------------

    Usage:
      type: object
      description: |
        One resource sample. The daemon owns these numbers: the API never
        computes usage, it only stores and forwards what the node reports.
      required: [cpu_percent, ram_mb, disk_mb, state]
      properties:
        cpu_percent:
          type: number
          format: double
          description: Same scale as the plan's `cpu_percent` -- 200 is two cores' worth.
          example: 63.4
        ram_mb:
          type: number
          format: double
          example: 2894.1
        disk_mb:
          type: number
          format: double
          example: 6142.0
        net_rx_bytes:
          type: integer
          format: int64
          example: 18452301
        net_tx_bytes:
          type: integer
          format: int64
          example: 9123774
        uptime_seconds:
          type: integer
          format: int64
          example: 25871
        state:
          $ref: '#/components/schemas/InstanceStatus'
        query:
          allOf:
            - $ref: '#/components/schemas/ServerQuery'
          nullable: true
          description: |
            Game servers only, and absent when the server did not answer the
            node's last ping. Passed through untouched, absence included: the
            node asked the server and this is what it said, or did not say.

    ScheduleAction:
      type: string
      description: |
        What a schedule does. Deliberately short: these five are what a nightly
        restart, a daily backup and a timed announcement need, and every one of
        them is something the owner could already do by hand.
      enum: [start, stop, restart, backup, command]
      example: restart

    Schedule:
      type: object
      description: One cron entry on one instance.
      required: [id, instance_id, name, cron, action, active]
      properties:
        id:
          type: string
          example: 0d0f7e21-4c2a-4f7d-9a11-5f2b8c3e77aa
        instance_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        name:
          type: string
          description: The owner's label for it. Shown in the list, never acted on.
          example: Neustart nachts
        cron:
          type: string
          description: |
            Five fields -- minute, hour, day of month, month, day of week --
            read in Europe/Berlin. Stored normalised to single spaces.
          example: 0 4 * * *
        action:
          $ref: '#/components/schemas/ScheduleAction'
        payload:
          type: string
          description: The console line, for the `command` action. Empty otherwise.
          example: say Der Server startet in 5 Minuten neu
        active:
          type: boolean
          description: A schedule that is off keeps its row and its slot, and does not fire.
          example: true
        last_run_at:
          type: string
          format: date-time
          nullable: true
          description: |
            The minute it last fired. This is the guard against a double firing
            across a restart, not only a display value -- it lives in the
            database rather than in memory so a deploy in the wrong minute
            cannot make a schedule run twice.
          example: '2026-08-22T02:00:00Z'
        last_error:
          type: string
          description: |
            Why the last firing did not happen, written by the runner. Empty
            when the last run was clean. It is in the row rather than only in
            the journal because the person who can fix it is looking at the
            schedule list.
          example: ''
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ScheduleRequest:
      type: object
      description: |
        A create or a full replacement. Every field is written on an update, so
        omitting `active` turns the schedule off.
      required: [cron, action]
      properties:
        name:
          type: string
          maxLength: 64
          example: Neustart nachts
        cron:
          type: string
          description: Five fields, Europe/Berlin, no more often than every ten minutes.
          example: 0 4 * * *
        action:
          $ref: '#/components/schemas/ScheduleAction'
        payload:
          type: string
          description: Required when `action` is `command`.
          example: say Der Server startet in 5 Minuten neu
        active:
          type: boolean
          default: false
          example: true

    Member:
      type: object
      description: |
        One person the owner shared an instance with. Only ever sent to the
        owner, which is why it may carry the invitee's address.
      required: [id, instance_id, user_id, role]
      properties:
        id:
          type: string
          description: The membership row's id. Not what the removal path takes -- that takes `user_id`.
          example: 6a0c1f38-2b74-4c05-a3d9-8e1f4b90c722
        instance_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        user_id:
          type: string
          description: The member's account id. This is what `DELETE /instances/{id}/members/{userId}` takes.
          example: 51575e4d-78d5-4e19-973b-187263ad671d
        role:
          type: string
          description: |
            `member` is the only role written today. It answers one question:
            may this person operate the server. Disposing of it -- deleting,
            renewing, sharing, scheduling -- stays with the owner.
          enum: [member]
          example: member
        email:
          type: string
          format: email
          description: Blank when the account was deleted and the row has not been cleaned up yet.
          example: freund@example.com
        display_name:
          type: string
          example: Jonas
        created_at:
          type: string
          format: date-time

    MemberAddRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
          description: The address the invitee registered with. Matched case-insensitively.
          example: freund@example.com

    ServerQuery:
      type: object
      description: |
        What the game server itself answered when the node last asked it -- the
        Server List Ping for Java, RakNet for Bedrock, roughly every twenty
        seconds.

        The whole field is absent when there was no answer, and that is kept
        distinct from an answer of zero players the whole way to the screen: a
        server that is starting up, generating a world or wedged says nothing,
        and reporting that as "0 Spieler" would be a lie about a server that is
        not up.
      required: [players, max_players]
      properties:
        players:
          type: integer
          example: 3
        max_players:
          type: integer
          example: 20
        motd:
          type: string
          description: The message of the day as the server sent it, colour codes stripped.
          example: Ein CitexNode Server
        version:
          type: string
          description: The server's own name for what it runs. Not a value this API chose.
          example: Paper 26.2
        protocol:
          type: integer
          description: The wire protocol number, which is what a client actually matches on.
          example: 776
        latency_ms:
          type: integer
          format: int64
          description: The round trip from the node, not from the player.
          example: 1

    RecoveryCodes:
      type: object
      description: |
        A freshly minted batch, in the clear. The API returns this shape exactly
        twice in an account's life -- when the second factor is switched on, and
        when a new batch is requested -- and never again: only SHA-256 hashes
        are stored.
      required: [codes]
      properties:
        codes:
          type: array
          description: Ten codes, formatted in two groups of five. Matched case-insensitively and with the hyphen optional, because they get typed off paper.
          items:
            type: string
            example: K7M2P-QR9TW

    Appearance:
      type: object
      description: How this server presents itself in a server list.
      required: [supported]
      properties:
        supported:
          type: boolean
          description: |
            False for a runtime whose MOTD is not a key in `server.properties`:
            the two proxies read their own config file, and the Bedrock server
            calls the field `server-name` and has no icon.
          example: true
        motd:
          type: string
          description: Unescaped, as players see it. A real newline for the second line, a real section sign for a colour code.
          example: "Mein Server\nJeden Abend ab 18 Uhr"
        icon:
          type: boolean
          description: Whether `server-icon.png` is in place. The image itself is not sent; the file manager already downloads files.
          example: true
        restart_required:
          type: boolean
          description: |
            Always true, and here to be rendered rather than decided. Both files
            are read once at startup, so a saved MOTD that has not appeared yet
            is a server that has not been restarted, not a broken form.
          example: true
        max_length:
          type: integer
          description: How long an MOTD may be, in characters. Sent so the form's counter is not a number in TSX.
          example: 300
        icon_size:
          type: integer
          description: The edge length Minecraft demands, in pixels. Same reason.
          example: 64

    SFTPAccess:
      type: object
      description: |
        How to reach one instance's files with an SFTP client, and whether there
        is anything to reach them with.

        The password appears exactly once, in the answer to the call that
        generated it. It is stored as an argon2id hash, so no later read can
        produce it again -- the only honest answer to "what was it" is a new one.
      required: [enabled, available]
      properties:
        enabled:
          type: boolean
          description: Whether this instance has an SFTP password at all.
          example: true
        available:
          type: boolean
          description: |
            Whether the node this instance sits on offers SFTP. False until the
            port is open in that node's firewall, and the panel says so rather
            than printing an address that refuses connections.
          example: true
        host:
          type: string
          description: The node's fqdn. An instance lives on exactly one node.
          example: node1.citexnode.de
        port:
          type: integer
          example: 2222
        username:
          type: string
          description: |
            The instance's daemon ref. Opaque and unique, which is what lets a
            node find the directory without a second table mapping pretty names
            to instances.
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        password:
          type: string
          description: Present only in the answer that generated it, never afterwards.
          example: kR7vQm2xTdWpLn9sBhZfC4jY

    AlertChannel:
      type: object
      description: |
        One instance's Discord webhook, as the form reads and writes it. The URL
        is write-only in both directions: it is never returned, and an empty
        `webhook` on a write means keep rather than remove.
      required: [configured]
      properties:
        configured:
          type: boolean
          readOnly: true
          description: Whether a channel is set. All the form needs to render "connected" against "add one".
          example: true
        webhook:
          type: string
          writeOnly: true
          description: |
            A Discord webhook URL. Empty keeps what is stored. Must start
            `https://discord.com/api/webhooks/` or
            `https://discordapp.com/api/webhooks/`.
          example: https://discord.com/api/webhooks/1234567890/abcdef
        clear:
          type: boolean
          writeOnly: true
          description: Remove the stored channel. The only way to remove one, because empty already means keep.
          example: false

    ActivityEntry:
      type: object
      description: One thing that happened to an instance.
      required: [id, action, at, actor]
      properties:
        id:
          type: string
          example: 3f6d0c9e-1b2a-4c88-9d31-7a55e0c4b912
        action:
          type: string
          description: The same string the admin audit log records. Render what you know, fall back to the raw value.
          example: instance.power
        at:
          type: string
          format: date-time
          example: '2026-08-22T03:00:00Z'
        actor:
          $ref: '#/components/schemas/ActivityActor'
        meta:
          type: object
          additionalProperties: true
          nullable: true
          description: |
            What the action carried, minus anything that identifies a person.
            A power row has `action`, a rename has `from` and `to`, a backup has
            `backup`. Absent when nothing survived the trim.
          example: { action: restart }
        instance:
          nullable: true
          description: |
            Which instance this happened to. Sent by `GET /me/activity`, where
            rows from several instances are mixed, and omitted by one instance's
            own log, where it would repeat the page heading on every line.
          allOf:
            - $ref: '#/components/schemas/ActivityInstanceRef'

    ActivityInstanceRef:
      type: object
      required: [id, name, kind]
      properties:
        id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        name:
          type: string
          example: Mein Testserver
        kind:
          $ref: '#/components/schemas/Kind'

    ActivityActor:
      type: object
      description: |
        Who did it, as much as the reader is entitled to know. Deliberately not
        the audit row's actor id and address.
      required: [self, scheduled, system]
      properties:
        self:
          type: boolean
          description: True when the caller did this themselves.
          example: false
        display_name:
          type: string
          description: Falls back to the address when the account has no display name. Empty for a deleted account.
          example: Jonas
        email:
          type: string
          format: email
          description: Owner-only. A member never sees another member's address here.
          example: freund@example.com
        scheduled:
          type: boolean
          description: |
            The row came from a schedule firing, not from a person. A schedule
            acts as the owner, so without this the log reads as the owner having
            been awake at three in the morning.
          example: true
        system:
          type: boolean
          description: |
            The platform wrote this row about itself: a crash, a suspension
            after a lapsed renewal, a purge after the grace period. Those rows
            carry no actor at all, and a client that renders "unknown actor" as
            a deleted account tells the reader something untrue about the two
            events they most want explained.
          example: false

    ConsoleLine:
      type: object
      description: One frame on the console socket -- a line of output, or a command echoed back.
      required: [stream, line]
      properties:
        stream:
          type: string
          enum: [stdout, stderr, system]
          example: stdout
        line:
          type: string
          example: '[12:03:41 INFO]: Done (4.812s)! For help, type "help"'

    Event:
      type: object
      description: One frame on the events socket.
      required: [kind, at]
      properties:
        kind:
          type: string
          description: |
            What happened. `usage` carries a `Usage` sample in `data`; `status`
            carries `{"status": "<lifecycle state>"}`; `console` carries a
            `ConsoleLine`; `credits` carries a balance change.
          enum: [usage, status, console, credits]
          example: usage
        instance_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        user_id:
          type: string
          example: 2f8b91c4-6d0a-4e73-9c15-8ab3d7e0f261
        at:
          type: string
          format: date-time
          example: '2026-08-18T12:03:41Z'
        data:
          description: Payload, shaped by `kind`.
          type: [object, 'null']
          additionalProperties: true

    FileEntry:
      type: object
      description: One entry in an instance's file manager, as the node reports it.
      required: [name, path, size, is_dir]
      properties:
        name:
          type: string
          example: server.properties
        path:
          type: string
          description: Relative to the instance's data directory.
          example: server.properties
        size:
          type: integer
          format: int64
          example: 1284
        mode:
          type: string
          example: '-rw-r--r--'
        is_dir:
          type: boolean
          example: false
        mime_type:
          type: string
          example: text/plain
        mod_time:
          type: string
          format: date-time
          example: '2026-08-17T20:41:12Z'
        symlink:
          type: boolean
          example: false
        writable:
          type: boolean
          description: False for the paths the daemon protects; a write is refused rather than silently ignored.
          example: true

    DaemonState:
      type: object
      description: |
        One instance as its node sees it. Decoded straight into the daemon's own
        report type, so the wire format has exactly one definition and cannot
        drift between the two ends.
      required: [ref, state]
      properties:
        ref:
          type: string
          description: |
            The daemon's own opaque handle for the instance, matched against the
            stored `daemon_ref` -- not the API's instance id. Unknown refs are
            skipped, not rejected.
          example: cx-7f3a91d0c48b
        state:
          allOf:
            - $ref: '#/components/schemas/InstanceStatus'
          description: |
            Runtime state. Folded into the stored status column unless the
            instance is `suspended` or `deleting`, which the API owns.
        cpu_percent:
          type: number
          format: double
          example: 63.4
        ram_mb:
          type: number
          format: double
          example: 2894.1
        disk_mb:
          type: number
          format: double
          example: 6142.0
        net_rx_bytes:
          type: integer
          format: int64
          example: 18452301
        net_tx_bytes:
          type: integer
          format: int64
          example: 9123774
        uptime_seconds:
          type: integer
          format: int64
          example: 25871
        ports:
          type: array
          items:
            type: integer
          example: [25031]
        updated_at:
          type: string
          format: date-time
          example: '2026-08-18T12:03:40Z'

    # --- pagination -------------------------------------------------------

    Page:
      type: object
      description: |
        The one envelope every paginated collection uses. `total` is the
        unfiltered count **for the current filter**, so a client can render "42
        von 120" without a second query. Per-endpoint schemas narrow `items`.
      required: [items, total, limit, offset]
      properties:
        items:
          type: array
          items: {}
        total:
          type: integer
          format: int64
          description: Rows matching the filter, ignoring limit and offset.
          example: 120
        limit:
          type: integer
          description: Effective page size after the server's cap was applied.
          example: 25
        offset:
          type: integer
          example: 0

    CreditTransactionPage:
      allOf:
        - $ref: '#/components/schemas/Page'
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/CreditTransaction'

    TicketPage:
      allOf:
        - $ref: '#/components/schemas/Page'
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/Ticket'

    UserPage:
      allOf:
        - $ref: '#/components/schemas/Page'
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/User'

    InstancePage:
      allOf:
        - $ref: '#/components/schemas/Page'
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/Instance'

    AuditLogPage:
      allOf:
        - $ref: '#/components/schemas/Page'
        - type: object
          properties:
            items:
              type: array
              items:
                $ref: '#/components/schemas/AuditLog'

    # --- request bodies ---------------------------------------------------

    RegisterRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          example: lena.hoffmann@example.de
        password:
          type: string
          format: password
          description: Checked against the strength rules; a weak one answers `password_weak`.
          example: korrektes-pferd-batterie-heftklammer
        display_name:
          type: string
          description: Optional. Falls back to the local part of the address.
          example: Lena Hoffmann
        locale:
          type: string
          description: BCP 47 tag. Decides the language of every mail.
          example: de
        referral_code:
          type: string
          description: Optional. Pays out only after this account verifies and creates an instance.
          example: JONAS-7XB1

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          example: lena.hoffmann@example.de
        password:
          type: string
          format: password
          example: korrektes-pferd-batterie-heftklammer
        totp_code:
          type: string
          description: Required only when the account has TOTP enabled; omitting it then answers `totp_required`.
          example: '482913'

    RefreshRequest:
      type: object
      required: [refresh_token]
      properties:
        refresh_token:
          type: string
          example: rt_9f3c1a7b52d84e60b8a4c7e0d1f52639

    EmailRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
          example: lena.hoffmann@example.de

    TokenRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: The single-use token from the mail. Only its hash is stored server-side.
          example: vt_5c1e9a73b0d24f68a95c3e7d18b2064f

    ResetPasswordRequest:
      type: object
      required: [token, password]
      properties:
        token:
          type: string
          example: rst_2a7d4b19c6e05f38b1d9a04c7e62f5b3
        password:
          type: string
          format: password
          example: neues-langes-passwort-2026

    SessionAlertRequest:
      type: object
      required: [token, action]
      properties:
        token:
          type: string
          description: |
            The token from the sign-in mail. It is `<session id>.<secret>`: the
            stored hash covers the whole string, so an id swapped in the URL no
            longer resolves to a row.
          example: 6d1a2c4e-9f3b-4a17-8c0d-2b5e7a91f4c3.9f2c7a15b0e34d6819ac5f7028be413d
        action:
          type: string
          enum: [device, all]
          description: |
            `device` signs out the sign-in the mail is about; `all` signs out
            every session of the account.
          example: device

    ChangePasswordRequest:
      type: object
      required: [current_password, new_password]
      properties:
        current_password:
          type: string
          format: password
          example: korrektes-pferd-batterie-heftklammer
        new_password:
          type: string
          format: password
          example: neues-langes-passwort-2026

    TOTPRequest:
      type: object
      required: [code]
      properties:
        code:
          type: string
          description: Current six-digit code from the authenticator.
          example: '482913'

    TOTPDisableRequest:
      type: object
      description: Both factors are required to remove the second one.
      required: [password, code]
      properties:
        password:
          type: string
          format: password
          example: korrektes-pferd-batterie-heftklammer
        code:
          type: string
          example: '482913'

    ProfileUpdateRequest:
      type: object
      description: |
        Sparse. An absent key leaves the field alone; a present empty string
        clears it. Nothing else about the account is editable here.
      properties:
        display_name:
          type: [string, 'null']
          example: Lena Hoffmann
        locale:
          type: [string, 'null']
          example: de
        avatar_url:
          type: [string, 'null']
          example: https://citexnode.de/avatars/lena.png
        accent:
          type: [string, 'null']
          description: |
            One of the values in `Accent`, or empty for the product's own
            brand. Refused otherwise: the value ends up in an attribute the
            stylesheet selects on, and an unchecked one would be a colour
            nobody styled.
          example: amber

    Accent:
      type: string
      description: |
        The action colour an account picked. Only the action colour changes --
        credits, running, warning and fault carry meaning and are not a
        preference.
      enum: ['', indigo, amber, rose, emerald, sky, violet]
      example: amber

    DeleteAccountRequest:
      type: object
      required: [confirm]
      properties:
        confirm:
          type: string
          format: email
          description: Must equal the account's own email address after normalisation.
          example: lena.hoffmann@example.de

    InstanceCreateRequest:
      type: object
      required: [kind, plan_key, egg_key, name]
      properties:
        kind:
          $ref: '#/components/schemas/Kind'
        plan_key:
          type: string
          description: A plan's `key` from `GET /plans`. Must be active.
          example: free-game
        egg_key:
          type: string
          description: An egg's `key` from `GET /eggs`. Its `min_ram_mb` must fit the plan.
          example: minecraft-paper
        name:
          type: string
          description: 3-32 characters from letters, digits, space, underscore, dot or hyphen.
          example: Bergmanns Survival
        ram_mb:
          type: integer
          description: Optional. Zero means the plan's full allowance; above it answers `limit_ram_exceeded`.
          example: 4096
        disk_mb:
          type: integer
          description: Optional. Zero means the plan's full allowance.
          example: 10240
        git_repo:
          type: string
          description: Bots only. Cloned on deploy.
          example: https://github.com/lenah/discord-bot
        env:
          type: object
          additionalProperties:
            type: string
          description: Initial environment variables, written non-secret. Use `PUT /instances/{id}/env/{key}` to seal one.
          example:
            MOTD: CitexNode Testserver

    InstanceUpdateRequest:
      type: object
      description: |
        Both fields optional, at least one required. An absent field is left as
        it is, which is why `restart_on_crash` is meaningful as `false`.
      properties:
        name:
          type: string
          example: Bergmanns Survival 2
        restart_on_crash:
          type: boolean
          description: Start the instance again after it goes down on its own.
          example: true

    PowerRequest:
      type: object
      required: [action]
      properties:
        action:
          type: string
          description: '`kill` skips the egg''s graceful stop command and terminates the container.'
          enum: [start, stop, restart, kill]
          example: restart

    EnvValueRequest:
      type: object
      required: [value]
      properties:
        value:
          type: string
          example: CitexNode Testserver
        secret:
          type: boolean
          description: True stores the value sealed and hides it from every read, permanently.
          default: false
          example: false

    WriteFileRequest:
      type: object
      required: [path]
      properties:
        path:
          type: string
          description: Relative to the instance's data directory.
          example: server.properties
        content:
          type: string
          description: The full new contents. Capped by `LIMIT_FILE_EDIT_BYTES` (1 MiB by default).
          example: |
            server-port=25565
            motd=CitexNode Testserver

    RenameFileRequest:
      type: object
      required: [from, to]
      properties:
        from:
          type: string
          example: plugins/EssentialsX.jar
        to:
          type: string
          example: plugins/disabled/EssentialsX.jar

    EggUpdateRequest:
      type: object
      required: [name, runtime, image, start_cmd]
      description: |
        The editable half of a template. `key` and `kind` are absent on purpose:
        neither is a thing to change on a live catalogue through a text field.
      properties:
        name:
          type: string
          example: 'Minecraft: Paper'
        description:
          type: string
        runtime:
          type: string
          description: For a game template, one of `GET /admin/runtimes`.
          example: minecraft-java
        image:
          type: string
          description: Container image including its tag. The tag decides which Java is inside.
          example: eclipse-temurin:21-jre
        start_cmd:
          type: string
          description: May contain `{{VAR}}` placeholders filled from the instance's variables.
          example: java -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}} nogui
        stop_cmd:
          type: string
          description: Written to stdin for a graceful stop.
          example: stop
        config_json:
          type: string
          description: JSON object with a `variables` array. Rejected as `descriptor` if it is anything else.
        min_ram_mb:
          type: integer
          example: 1024
        sort_order:
          type: integer
        active:
          type: boolean
          description: False removes it from `GET /eggs`. Existing instances keep running.

    PluginProject:
      type: object
      properties:
        id: { type: string, example: hXiIvTyT }
        slug: { type: string, example: essentialsx }
        title: { type: string, example: EssentialsX }
        description: { type: string }
        icon_url: { type: string }
        downloads: { type: integer }
        categories:
          type: array
          items: { type: string }

    PluginCatalogue:
      type: object
      required: [supported, results]
      properties:
        supported:
          type: boolean
          description: False for a runtime that loads nothing. Everything else is then absent.
        loader:
          type: string
          description: What Modrinth calls this runtime's loader.
          example: paper
        directory:
          type: string
          description: Where an install lands.
          enum: [plugins, mods]
        game_version:
          type: string
          description: The server's own Minecraft version, absent when it is set to `latest`.
        results:
          type: array
          items:
            $ref: '#/components/schemas/PluginProject'

    PluginVersion:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        version_number: { type: string, example: 2.22.0 }
        version_type:
          type: string
          enum: [release, beta, alpha]
        game_versions:
          type: array
          items: { type: string }
        matches_server:
          type: boolean
          nullable: true
          description: |
            Whether this release names the server's own Minecraft version.

            Three states, and the third is the common one: **absent** means the
            instance is set to `latest` and nothing knows which version it
            resolved to. A plain boolean made that read as "does not match", so
            the warning appeared on nearly every install and stopped being read.

            `false` is not a refusal -- most plugins keep working across a
            release -- but it is the one fact worth showing next to the jar.
        file:
          type: object
          properties:
            url: { type: string }
            filename: { type: string }
            sha1: { type: string }
            size: { type: integer, format: int64 }

    InstalledPlugin:
      type: object
      properties:
        filename: { type: string }
        size_bytes: { type: integer, format: int64 }
        mod_time: { type: string, format: date-time }

    BotAccount:
      type: object
      required: [linked, credits, verified]
      properties:
        linked:
          type: boolean
          description: False when the Discord id belongs to no account. Everything else is then absent.
        user_id: { type: string }
        display_name: { type: string }
        credits:
          type: integer
          description: The balance, zero when unlinked.
        verified:
          type: boolean
          description: Whether the account has confirmed its email address.

    BotInstance:
      type: object
      required: [id, name, kind, status, ram_mb]
      properties:
        id: { type: string }
        name: { type: string }
        kind:
          type: string
          enum: [game, bot]
        status: { type: string }
        ram_mb:
          type: integer
          description: Effective RAM, boosts included.
        address:
          type: string
          description: What a player types. Absent for a bot instance, which has no port anyone connects to.

    ModpackCatalogue:
      type: object
      required: [supported, results]
      properties:
        supported:
          type: boolean
          description: |
            False for a runtime that loads no mods. Everything else is then
            absent. Unlike the plugin catalogue this is false for Paper and the
            proxies too: they take plugins, and a pack there would write a
            `mods/` directory nothing ever reads.
        loader:
          type: string
          description: What Modrinth calls this runtime's loader.
          example: fabric
        game_version:
          type: string
          description: The server's own Minecraft version, absent when it is set to `latest`.
          example: 1.21.1
        results:
          type: array
          items:
            $ref: '#/components/schemas/PluginProject'

    ModpackInstall:
      type: object
      description: |
        What was accepted, so the page can name the pack while the files are
        still arriving. The result itself comes over the `events` channel.
      required: [project, name, version, game_version, files, bytes]
      properties:
        project: { type: string, example: fabulously-optimized }
        name:
          type: string
          description: The pack's own name, out of its manifest.
          example: Fabulously Optimized
        version: { type: string, example: 6.5.0 }
        game_version:
          type: string
          description: The Minecraft version the pack was built for.
          example: 1.21.1
        loader_version:
          type: string
          description: |
            The loader build the pack asks for. Recorded rather than enforced:
            the instance installs the newest stable loader for its Minecraft
            version, which in practice runs packs pinned to an older one.
          example: 0.19.3
        files:
          type: integer
          description: How many files will be written, manifest and overrides together.
          example: 50
        bytes:
          type: integer
          format: int64
          description: The manifest own total for the files it names, before overrides.

    SupportPIN:
      type: object
      description: |
        Empty when there is no live PIN, which is the normal state rather than
        an error.
      properties:
        pin:
          type: string
          description: Eight digits. Sealed at rest, readable by its owner, never shown to anybody else.
          example: '40318827'
        expires_at:
          type: string
          format: date-time
          description: 24 hours after it was generated.

    EggCreateRequest:
      type: object
      required: [key, kind, name, runtime, image, start_cmd]
      description: |
        Everything `EggUpdateRequest` carries, plus the two fields that are
        settable exactly once.
      allOf:
        - $ref: '#/components/schemas/EggUpdateRequest'
        - type: object
          properties:
            key:
              type: string
              description: |
                Lowercased on the way in, and must match
                `^[a-z0-9]+(?:-[a-z0-9]+)*$`. It ends up in URLs and in every
                provisioned instance's spec on disk, which is why it cannot be
                changed afterwards. An existing key is refused as `key`/`taken`.
              example: minecraft-paper-snapshot
            kind:
              type: string
              enum: [game, bot]
              description: |
                Which product the template belongs to, and whether its runtime is
                checked against a version provider. Bot templates have no
                versions to pick and are not checked.

    NewsPost:
      type: object
      properties:
        id: { type: string }
        slug: { type: string, example: lagom-redesign }
        kind:
          type: string
          enum: [news, changelog]
        title_de: { type: string }
        title_en: { type: string }
        body_de:
          type: string
          description: Plain text in blocks -- paragraphs separated by a blank line, `## ` headings, `- ` list items.
        body_en: { type: string }
        published_at:
          type: string
          format: date-time
          nullable: true
          description: Null is a draft.
        created_by: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    NewsRequest:
      type: object
      required: [slug, title_de, title_en, body_de, body_en]
      properties:
        slug:
          type: string
          pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
        kind:
          type: string
          enum: [news, changelog]
          default: news
        title_de: { type: string }
        title_en: { type: string }
        body_de: { type: string, maxLength: 20000 }
        body_en: { type: string, maxLength: 20000 }
        published_at:
          type: string
          format: date-time
          nullable: true

    CouponPublic:
      type: object
      properties:
        code: { type: string, example: BLACK40 }
        percent: { type: integer, example: 40 }
        starts_md: { type: string, example: '11-27' }
        ends_md: { type: string, example: '11-30' }

    Coupon:
      type: object
      properties:
        id: { type: string }
        code: { type: string }
        percent: { type: integer }
        starts_md: { type: string, description: 'Month-day the yearly window opens, e.g. 11-27.' }
        ends_md: { type: string, description: 'Month-day it closes; before starts_md wraps the new year.' }
        active: { type: boolean }
        note: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CouponRequest:
      type: object
      required: [code, percent, starts_md, ends_md]
      properties:
        code: { type: string, pattern: '^[A-Z0-9]{3,24}$' }
        percent: { type: integer, minimum: 1, maximum: 90 }
        starts_md: { type: string }
        ends_md: { type: string }
        active: { type: boolean }
        note: { type: string }

    TicketTemplate:
      type: object
      properties:
        id: { type: string }
        title:
          type: string
          example: Server startet nicht -- Logs anfordern
        body:
          type: string
          description: Stored exactly as typed. No placeholder syntax -- templates are pasted into the reply box and edited there.
        category:
          type: string
          description: Empty means the template is offered everywhere.
          enum: ['', general, server, bot, billing, abuse]
        locale:
          type: string
          enum: [de, en]
        sort_order: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TicketTemplateRequest:
      type: object
      required: [title, body]
      properties:
        title:
          type: string
        body:
          type: string
          description: At most 8000 characters. These are whole answers, not snippets.
        category:
          type: string
          description: Empty offers the template everywhere. Anything else must be a real ticket category.
          enum: ['', general, server, bot, billing, abuse]
        locale:
          type: string
          enum: [de, en]
          default: de
        sort_order:
          type: integer

    PlatformSettings:
      type: object
      required: [registration_open]
      description: |
        The operator switches. The alert webhook URL is write-only: it is a
        credential, and one that is never sent back cannot leak through a
        cache, a screenshot or an over-eager logger. The same rule the node
        editor applies to a node token.
      properties:
        registration_open:
          type: boolean
          description: |
            False makes `POST /auth/register` answer `403 registration_closed`
            before it checks anything else. A database with no settings row
            means open -- the alternative is a fresh install that silently
            refuses its first user and looks healthy from a green build.
        registration_note:
          type: string
          description: Shown to whoever hits the closed door. Without one, every such visitor opens a ticket asking when it reopens.
        alert_webhook_set:
          type: boolean
          readOnly: true
          description: Whether a Discord webhook is configured. The URL itself is never returned.
        alert_webhook:
          type: string
          writeOnly: true
          description: |
            A Discord webhook URL, sealed at rest. It is the only channel this
            platform has while `MAIL_ENABLED` is false, and the alert loop posts
            the status page's findings to it -- on the edges only, because
            repeating a standing finding every thirty seconds produces a channel
            nobody reads by the second hour.

            **Empty means keep**, so a form that was never given the stored URL
            does not have to send one back. Use `alert_webhook_clear` to remove
            it. Must start with `https://discord.com/api/webhooks/`, refused as
            `alert_webhook`/`discord_webhook` otherwise: a typo here is an alert
            channel that silently goes nowhere and nothing else would notice.
          example: https://discord.com/api/webhooks/123/abc
        alert_webhook_clear:
          type: boolean
          writeOnly: true
          description: Remove the stored webhook. The only way, because empty already means keep.

    PublicPlatform:
      type: object
      required: [registration_open]
      properties:
        registration_open: { type: boolean }
        registration_note:
          type: string
          description: Present only while registration is closed.
        passkeys_enabled:
          type: boolean
          description: |
            Whether this deployment can do WebAuthn at all. It needs a domain to
            bind a key to, which a checkout without a `WEB_URL` does not have.
            Public, because the login page has to decide whether to offer the
            button and belongs to nobody yet.
        passkey_rp_id:
          type: string
          description: |
            The domain a passkey is bound to. Published because `passkeys_enabled`
            is not enough on its own: this platform also answers on a second
            domain, and a key minted for one cannot be used from the other --
            the browser refuses before the request reaches the API. A client
            offers the button only where its own host is this value or a
            subdomain of it. Absent when passkeys are off.
          example: citexnode.de

    SchedulerRuns:
      type: object
      description: |
        When each tick last completed, held in memory rather than in the
        database -- that is exactly the question being asked, and a timestamp
        written by a process that has since died would report a scheduler that
        is running.
      properties:
        started_at: { type: string, format: date-time }
        lifecycle_at:
          type: string
          format: date-time
          description: Renewals, suspensions, deletions and schedules. Fires every minute; a zero value or a gap over three minutes raises `scheduler_stalled`.
        health_at:
          type: string
          format: date-time
          description: Node health poll. Every 30 seconds.
        hourly_at:
          type: string
          format: date-time
          description: Usage pruning, renewal reminders, session and audit cleanup. Zero for up to an hour after a deploy, which is normal.

    StatusWarning:
      type: object
      required: [code]
      properties:
        code:
          type: string
          enum:
            - mail_disabled
            - databases_disabled
            - databases_unreachable
            - scheduler_stalled
            - node_offline
            - node_silent
            - node_degraded
            - registration_closed
        detail:
          type: string
          description: Names the subject -- a node's name, a driver's error. Not translated.

    PlatformStatus:
      type: object
      properties:
        now: { type: string, format: date-time }
        scheduler:
          $ref: '#/components/schemas/SchedulerRuns'
        mail:
          type: object
          properties:
            enabled: { type: boolean }
            host: { type: string }
        databases:
          type: object
          properties:
            enabled:
              type: boolean
              description: Whether MYSQL_DSN is configured at all.
            reachable:
              type: boolean
              description: Whether the server answered a ping just now. Separate from `enabled` because "switched off" and "on and not answering" are different operator problems.
            host: { type: string }
            error: { type: string }
        nodes:
          type: array
          items:
            $ref: '#/components/schemas/NodeHealth'
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/StatusWarning'

    ReinstallRequest:
      type: object
      properties:
        version:
          type: string
          description: |
            One of the versions `GET /eggs/{key}/versions` lists for this
            instance's template. Empty or absent means the recommended one.
          example: 1.21.8
        java_major:
          type: integer
          description: |
            Land on another rung of the template's Java ladder. Absent keeps the
            one the instance is on, which is what a plain version change means.

            Version and JVM belong together: going back to 1.8 means going back
            to Java 8, and asking somebody to change one, watch it fail, then
            change the other is asking them to break their own server to learn
            the rule.
          example: 8

    DirectoryRequest:
      type: object
      required: [path]
      properties:
        path:
          type: string
          example: plugins/disabled

    NameRequest:
      type: object
      description: A human label. For databases it is accepted and ignored -- the schema name is generated.
      properties:
        name:
          type: string
          example: Vor dem Plugin-Update

    TicketCreateRequest:
      type: object
      required: [subject, category, body]
      properties:
        subject:
          type: string
          example: Server startet nach Plugin-Update nicht
        category:
          type: string
          enum: [general, server, bot, billing, abuse]
          example: server
        priority:
          type: string
          enum: [low, normal, high]
          default: normal
          example: normal
        body:
          type: string
          description: The first message. Trimmed and length-checked by the service.
          example: Der Server bleibt nach dem Update im Status "starting" haengen.
        instance_id:
          type: string
          description: Optional. Verified to belong to the caller before it is attached.
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231

    TicketReplyRequest:
      type: object
      required: [body]
      properties:
        body:
          type: string
          example: Ich habe das Plugin entfernt, jetzt startet er wieder. Danke!

    WSTicketRequest:
      type: object
      required: [instance_id, scope]
      properties:
        instance_id:
          type: string
          example: b3f1a7d5-8c92-4e60-a1b4-77de0c5f9231
        scope:
          type: string
          description: The channel. Combined with the instance id into the ticket's scope.
          enum: [console, events]
          example: console

    TeamRole:
      type: object
      description: |
        A public title the operator makes once and hands to people. It grants
        nothing -- `users.role` still decides what an account may do.
      required: [id, name, rank]
      properties:
        color_from: { type: string, description: '#RRGGBB or empty; first colour of the ring gradient.' }
        color_to: { type: string, description: '#RRGGBB or empty; second colour of the ring gradient.' }
        badge_color: { type: string, description: '#RRGGBB or empty; the role badge.' }
        id: { type: string, format: uuid }
        name:
          type: string
          maxLength: 80
          example: Betrieb
        description:
          type: string
          maxLength: 300
          description: One sentence under the name on /team. May be empty.
          example: Nodes, Netz und alles, was um drei Uhr nachts piept.
        rank:
          type: integer
          description: Order on the page, low first.
          example: 10
        permissions:
          type: array
          description: |
            What the role may do on the operator surface. Empty means the role is
            a public title and nothing more, which is what every role written
            before permissions existed is. Only an owner may change this list.
          items:
            $ref: '#/components/schemas/Permission'
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AdminMe:
      type: object
      required: [role, owner, permissions, catalogue]
      properties:
        role:
          type: string
          enum: [owner, user]
          description: |
            `owner` is full rights and the only role that may hand rights out.
            Anything else reaches this surface through a team role's permissions.
        owner:
          type: boolean
          description: Whether every permission is granted regardless of the list below.
        permissions:
          type: array
          description: What this caller may do. Empty for an owner, who bypasses the list.
          items:
            $ref: '#/components/schemas/Permission'
        catalogue:
          type: array
          description: Every permission that exists, in the order a surface should show it.
          items:
            $ref: '#/components/schemas/Permission'

    Permission:
      type: string
      description: |
        One area of the operator surface, read or write. `danger` is the two
        irreversible ones -- deleting an account, deleting somebody's server --
        kept apart from `write` because they end a customer relationship rather
        than interrupt it.
      enum:
        - users.read
        - users.write
        - users.danger
        - instances.read
        - instances.write
        - instances.danger
        - catalog.read
        - catalog.write
        - nodes.read
        - nodes.write
        - tickets.read
        - tickets.write
        - economy.read
        - economy.write
        - platform.read
        - platform.write
        - team.read
        - team.write
        - audit.read

    TeamRoleRequest:
      type: object
      required: [name]
      properties:
        color_from: { type: string, description: '#RRGGBB or empty; first colour of the ring gradient.' }
        color_to: { type: string, description: '#RRGGBB or empty; second colour of the ring gradient.' }
        badge_color: { type: string, description: '#RRGGBB or empty; the role badge.' }
        name:
          type: string
          maxLength: 80
          example: Support
        description:
          type: string
          maxLength: 300
          example: Beantwortet Tickets und findet heraus, was wirklich kaputt ist.
        rank:
          type: integer
          default: 0
          example: 20
        permissions:
          type: array
          description: |
            Owner-only. A non-owner may send the list the role already has --
            renaming is `team.write` -- but any change to it is refused.
          items:
            $ref: '#/components/schemas/Permission'

    TeamRoleAssignment:
      type: object
      description: An empty or omitted id takes the account off the page.
      properties:
        role_id:
          type: string
          format: uuid
          nullable: true

    TeamMember:
      type: object
      description: |
        One entry on the public /team page. Deliberately not a User: no address,
        no id, nothing an account object carries.
      required: [display_name, role_name, rank]
      properties:
        display_name: { type: string, example: DerGamer09 }
        avatar_url: { type: string, example: '' }
        role_name: { type: string, example: Betrieb }
        role_description:
          type: string
          example: Nodes, Netz und alles, was um drei Uhr nachts piept.
        rank: { type: integer, example: 10 }

    AdminReasonRequest:
      type: object
      description: The reason is stored and mailed. Empty is allowed -- a suspension with no stated reason still applies.
      properties:
        reason:
          type: string
          example: Verstoss gegen die Nutzungsbedingungen (Abuse-Meldung 2026-08-17)

    AdminCreditsRequest:
      type: object
      required: [amount]
      properties:
        amount:
          type: integer
          description: Signed. Negative is a correction and goes through the spend path.
          example: 50
        note:
          type: string
          description: Shown to the user in their ledger.
          example: Entschaedigung fuer Node-Ausfall am 17.08.
    AdminRoleRequest:
      type: object
      required: [role]
      properties:
        role:
          $ref: '#/components/schemas/Role'

    PlanInput:
      type: object
      description: |
        The plan editor's payload, validated for internal consistency: `ram_mb`
        >= 128, `cpu_percent` >= 10, `disk_mb` >= 256, `max_instances` >= 1,
        `renewal_days` >= 1, `renewal_cost` >= 0.
      required: [key, kind, name, ram_mb, cpu_percent, disk_mb, max_instances, renewal_days]
      properties:
        key:
          type: string
          description: Unique. What instances reference; a duplicate answers `conflict`.
          example: free-bot
        kind:
          $ref: '#/components/schemas/Kind'
        name:
          type: string
          example: Free Bot
        description:
          type: string
          example: Kostenloser Bot-Host mit 512 MB RAM.
        ram_mb:
          type: integer
          minimum: 128
          example: 512
        cpu_percent:
          type: integer
          minimum: 10
          example: 50
        disk_mb:
          type: integer
          minimum: 256
          example: 2048
        max_instances:
          type: integer
          minimum: 1
          example: 2
        backup_slots:
          type: integer
          example: 0
        database_slots:
          type: integer
          example: 1
        extra_ports:
          type: integer
          example: 0
        renewal_days:
          type: integer
          minimum: 1
          example: 7
        renewal_cost:
          type: integer
          minimum: 0
          example: 10
        grace_days:
          type: integer
          example: 14
        sort_order:
          type: integer
          example: 20
        active:
          type: boolean
          example: true

    MoveResult:
      type: object
      description: |
        What a completed move changed. The port is kept -- see the endpoint --
        so what a player notices is the host: the instance answers at the new
        node's address, and every saved entry in a server list points at the old
        machine.
      required: [instance, from_node, to_node, from_port, to_port]
      properties:
        instance:
          $ref: '#/components/schemas/Instance'
        from_node:
          type: string
          example: node1
        to_node:
          type: string
          example: node2
        from_port:
          type: integer
          example: 25501
        to_port:
          type: integer
          description: The same number as `from_port`. Kept rather than reallocated; only the host changes.
          example: 25501
        bytes:
          type: integer
          format: int64
          description: How much was transferred, as the target node counted it.
          example: 481296384
        took:
          type: string
          description: Wall clock, rounded to the second.
          example: 1m42s

    NodeInput:
      type: object
      description: |
        Node registration and editing. `port_range_to` must exceed
        `port_range_from`, otherwise the allocator has no port to hand out.
      required: [name, daemon_url]
      properties:
        name:
          type: string
          example: Falkenstein 01
        fqdn:
          type: string
          description: What instance addresses are built from.
          example: fsn1-01.nodes.citexnode.de
        region:
          type: string
          example: de-fsn
        kind:
          type: string
          enum: [game, bot, mixed]
          example: mixed
        daemon_url:
          type: string
          description: Reachable from the API only. Never served back.
          example: https://10.0.1.11:8443
        daemon_token:
          type: string
          description: Sealed before storage. **Empty means keep the current one**, so the editor need not round-trip a secret it is not shown.
          example: ''
        max_ram_mb:
          type: integer
          example: 131072
        max_disk_mb:
          type: integer
          example: 2097152
        port_range_from:
          type: integer
          example: 25000
        port_range_to:
          type: integer
          example: 25999
        status_component_id:
          type: string
          description: |
            The Instatus component this node is published as, or empty when it
            is not published.

            It exists because Instatus cannot measure a node. Its probes reach a
            URL from the internet; a node answers only from the API's address,
            behind an nginx allowlist, and is healthy only when its daemon
            answers and its disk has room. A ping from outside says the box has
            power, which is the one thing nobody was worried about.

            With an id set, the health tick writes the real answer every thirty
            seconds: operational, degraded, or a major outage for a node that is
            down or has gone silent. A component an operator has put under
            maintenance is never overwritten.

            Not a credential, unlike the daemon token beside it, so it is
            returned as well as accepted -- the editor has to render the current
            value in order to change it. Clearing the field stops publishing.
          example: cmt5wmslw11xz0kplsxyopkw9
        active:
          type: boolean
          description: False drains the node -- no new placements, existing instances keep running.
          example: true

    UsagePushRequest:
      type: object
      description: What a node posts on its sampling tick.
      required: [node_id, instances]
      properties:
        node_id:
          type: string
          description: Marks the node seen before anything else, so an empty node still counts as alive.
          example: node-fsn1-01
        instances:
          type: array
          description: At most 500 are ingested; the tail is dropped rather than the batch rejected.
          items:
            $ref: '#/components/schemas/DaemonState'

    # --- response bodies --------------------------------------------------

    AuthSession:
      allOf:
        - $ref: '#/components/schemas/TokenPair'
        - type: object
          description: The token pair with the account inlined, so the dashboard renders without a second call.
          required: [user]
          properties:
            user:
              $ref: '#/components/schemas/User'

    BillingView:
      type: object
      description: The billing configuration as the admin editor reads it. Sealed secrets appear only as booleans.
      required: [mode, credit_order_deadline, stripe_publishable_key, stripe_secret_set, stripe_webhook_set, paypal_client_id, paypal_secret_set, paypal_sandbox]
      properties:
        mode:
          type: string
          enum: [credits, money_manual, money_gateway]
        credit_order_deadline:
          type: string
          format: date-time
        stripe_publishable_key:
          type: string
        stripe_secret_set:
          type: boolean
        stripe_webhook_set:
          type: boolean
        paypal_client_id:
          type: string
        paypal_secret_set:
          type: boolean
        paypal_sandbox:
          type: boolean

    BillingInput:
      type: object
      description: |
        The editor's write shape. Empty secret strings mean "keep what is
        stored"; the clear flags are the only way to remove a credential.
      required: [mode]
      properties:
        mode:
          type: string
          enum: [credits, money_manual, money_gateway]
        credit_order_deadline:
          type: string
          format: date-time
        stripe_publishable_key:
          type: string
        stripe_secret_key:
          type: string
        stripe_webhook_secret:
          type: string
        paypal_client_id:
          type: string
        paypal_client_secret:
          type: string
        paypal_sandbox:
          type: boolean
        clear_stripe:
          type: boolean
        clear_paypal:
          type: boolean

    PublicEconomy:
      type: object
      description: |
        The publicly quotable credit numbers, served unauthenticated. A subset of
        the credit config by construction, so a field added to the config does
        not appear here by default.
      required: [starter_credits, daily_bonus, daily_cooldown_hours, referral_bonus, boost_ram_mb, boost_ram_days, boost_ram_cost]
      properties:
        starter_credits:
          type: integer
          example: 100
        daily_bonus:
          type: integer
          example: 10
        daily_cooldown_hours:
          type: integer
          example: 24
        referral_bonus:
          type: integer
          example: 50
        boost_ram_mb:
          type: integer
          example: 2048
        boost_ram_days:
          type: integer
          example: 3
        boost_ram_cost:
          type: integer
          example: 40
        currency:
          type: string
          enum: [eur]
          description: |
            The unit of every integer amount: euro cents since 2026-08-30. The
            credits era stored the same integers under another name, so
            1 former credit equals 1 cent.
          example: eur
        billing_mode:
          type: string
          enum: [credits, money_manual, money_gateway]
          description: |
            Which billing model is active. Anything but `credits` means the
            credit economy is winding down: earning is off, and ordering closes
            at `credit_order_deadline`.
          example: credits
        topup_providers:
          type: array
          items: { type: string, enum: [stripe, paypal] }
          description: |
            What a signed-in customer can top their balance up with right now:
            configured gateways while billing is in a money mode. Empty means
            the top-up card shows the bank-transfer fallback.
        topup_min_cents:
          type: integer
          example: 500
        topup_max_cents:
          type: integer
          example: 10000
        credit_order_deadline:
          type: string
          format: date-time
          description: |
            When credit-based ordering closes. Only meaningful while
            `billing_mode` is not `credits`; absent or zero otherwise.

    CreditsEconomy:
      type: object
      description: |
        The member-facing projection of the credit config: what a user needs to
        decide, and none of the operator knobs. Adding a field to the economy must
        not leak it here.
      required: [daily_bonus, referral_bonus, boost_ram_mb, boost_ram_days, boost_ram_cost]
      properties:
        daily_bonus:
          type: integer
          example: 10
        referral_bonus:
          type: integer
          example: 50
        boost_ram_mb:
          type: integer
          example: 2048
        boost_ram_days:
          type: integer
          example: 3
        boost_ram_cost:
          type: integer
          example: 40

    CreditsOverview:
      type: object
      required: [balance, daily, economy]
      properties:
        balance:
          type: integer
          example: 138
        daily:
          $ref: '#/components/schemas/DailyStatus'
        economy:
          $ref: '#/components/schemas/CreditsEconomy'

    CreditsClaimResult:
      type: object
      description: The written ledger row and the balance it produced, so no follow-up read is needed.
      required: [transaction, balance]
      properties:
        transaction:
          $ref: '#/components/schemas/CreditTransaction'
        balance:
          type: integer
          example: 148

    InstanceUsage:
      type: object
      description: |
        The live sample next to the ceilings it is measured against.
        `ram_limit_mb` is the effective limit, boosts included -- a bar drawn
        against the unboosted number would read over 100% for a user who just
        paid to avoid exactly that.
      required: [ram_limit_mb, cpu_limit_percent, disk_limit_mb, status]
      properties:
        usage:
          oneOf:
            - $ref: '#/components/schemas/Usage'
            - type: 'null'
          description: Null when the node has not reported yet.
        ram_limit_mb:
          type: integer
          example: 6144
        cpu_limit_percent:
          type: integer
          example: 200
        disk_limit_mb:
          type: integer
          example: 10240
        status:
          $ref: '#/components/schemas/InstanceStatus'

    PowerAccepted:
      type: object
      description: The action was handed to the daemon. Watch the events socket for the real transition.
      required: [accepted, action]
      properties:
        accepted:
          type: boolean
          example: true
        action:
          type: string
          enum: [start, stop, restart, kill]
          example: restart

    Accepted:
      type: object
      description: The node does the work after the call returns.
      required: [accepted]
      properties:
        accepted:
          type: boolean
          example: true

    DirectoryCreated:
      type: object
      required: [path]
      properties:
        path:
          type: string
          example: plugins/disabled

    WSTicketResponse:
      type: object
      description: Append the ticket to the socket URL as `?ticket=...`.
      required: [ticket, expires_at]
      properties:
        ticket:
          type: string
          example: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6ImNvbnNvbGU6YjNmMSJ9
        expires_at:
          type: string
          format: date-time
          description: About a minute out. Sufficient rather than load-bearing -- ownership is re-checked on connect.
          example: '2026-08-18T12:04:41Z'

    UsageIngestResult:
      type: object
      description: |
        Counts rather than 204, because a `skipped` that stays non-zero is the
        only visible symptom of a daemon running a stale instance list.
      required: [recorded, skipped, dropped]
      properties:
        recorded:
          type: integer
          description: Samples applied to the live gauges.
          example: 42
        skipped:
          type: integer
          description: Refs the API no longer knows, or refs belonging to another node.
          example: 1
        dropped:
          type: integer
          description: Reports past the 500-per-push cap.
          example: 0
