openapi: 3.0.3
info:
  title: nxdns admin API
  version: "1.0"
  description: |
    The REST API of the nxdns DNS sinkhole, served by nxdns itself on
    `web.bind:web.port` (plain HTTP; TLS termination is the operator's proxy).

    Conventions, which every endpoint follows:

    - All JSON field names are snake_case, matching settings keys and SQL
      column names.
    - Every error response carries the envelope `{"error": "<message>"}`.
      Internal detail never reaches the wire; a 500 body is generic and the
      cause goes to the server log.
    - Request bodies are strict: an unknown field is a 400, a body over
      1 MiB is a 413.
    - Authentication is enabled iff `web.password_hash` is set. When it is,
      every operation marked with `sessionCookie` security answers 401
      without a valid session cookie. When it is not, no operation requires
      authentication. Monitoring endpoints, the login, this document and the
      static assets are always open.
    - Requests spend one token of the per-address bucket
      (`web.api_rate_limit_per_min`); over-budget requests answer 429 with a
      `Retry-After` header. `/metrics` and `/api/health` are exempt so a
      Prometheus scrape can never be throttled; `/api/queries/live` is
      exempt because one long-lived stream is bounded by
      `web.sse_max_connections_per_ip` instead.
    - Mutations to groups, blocklists, rules, local records, forward zones,
      clients and client prefixes take effect live. Upstreams and
      `/api/settings` are restart-required.
    - nxdns runs under one of two configuration authorities. Started with
      `--config=<file>`, that file is the sole declarative source, and every
      operation that writes configuration answers 403 with the same error
      envelope, naming the file. Operations that change runtime state —
      `/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the
      login and the logout — stay live, as does `DELETE /api/clients/{id}`
      for a client the file does not declare. `GET /api/config/status`
      reports the live authority, so a client reads the mode rather than
      discovering it from a rejection.

servers:
  - url: /

security:
  - sessionCookie: []

paths:
  /metrics:
    get:
      summary: Prometheus metrics
      description: |
        Prometheus text exposition format 0.0.4, `nxdns_` prefix. Always
        unauthenticated and never rate limited.
      security: []
      responses:
        "200":
          description: Metrics exposition.
          content:
            text/plain:
              schema:
                type: string

  /api/health:
    get:
      summary: Health rollup
      description: |
        Always 200. `status` is `degraded` when, and only when, one of the five
        condition objects is in a degrading state: protection `unavailable`,
        upstreams `unavailable`, query history `losing` or `failed`,
        diagnostics `unavailable`, or disk `low` or `critical`. A paused
        protection is an operator's own choice and does not degrade. Always
        unauthenticated and never rate limited.
      security: []
      responses:
        "200":
          description: Health snapshot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"

  /api/version:
    get:
      summary: Build and uptime
      security: []
      responses:
        "200":
          description: Version information.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Version"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/openapi.yaml:
    get:
      summary: This document
      security: []
      responses:
        "200":
          description: The OpenAPI contract, verbatim.
          content:
            application/yaml:
              schema:
                type: string
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/auth/login:
    post:
      summary: Log in
      description: |
        Verifies the admin password. On success the response sets the
        `nxdns_session` cookie (`HttpOnly; SameSite=Lax; Path=/`). When no
        password is configured the request succeeds without minting a
        session and `auth_required` is false.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoginRequest"
      responses:
        "200":
          description: Authenticated; the cookie is set iff `auth_required`.
          headers:
            Set-Cookie:
              schema:
                type: string
              description: "`nxdns_session=<token>; HttpOnly; SameSite=Lax; Path=/; Max-Age=<ttl>`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoginResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Wrong or empty password.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/auth/logout:
    post:
      summary: Log out
      description: Deletes the session and clears the cookie. Always succeeds.
      responses:
        "200":
          description: Logged out; the cookie is cleared.
          headers:
            Set-Cookie:
              schema:
                type: string
              description: "`nxdns_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`"
          content:
            application/json:
              schema:
                type: object
                required: [authenticated]
                properties:
                  authenticated:
                    type: boolean
                    enum: [false]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/queries:
    get:
      summary: Query log page
      description: |
        Keyset pagination over the query log, newest first. Follow
        `next_before` until it is null.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
        - name: before
          in: query
          description: Return rows with id strictly below this cursor.
          schema: { type: integer, minimum: 1 }
        - name: domain
          in: query
          description: Substring match; `%` and `_` are literal.
          schema: { type: string, maxLength: 253 }
        - name: client
          in: query
          description: Exact client address.
          schema: { type: string, maxLength: 64 }
        - name: blocked
          in: query
          schema: { type: boolean }
        - name: since
          in: query
          description: Unix seconds, inclusive.
          schema: { type: integer }
        - name: until
          in: query
          description: Unix seconds, exclusive.
          schema: { type: integer }
      responses:
        "200":
          description: One page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QueriesPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/queries/{id}:
    get:
      summary: One query, fully explained
      description: |
        The full provenance of one logged query: what was asked, which group's
        policy applied, what that policy decided and matched on, what was
        rewritten, where the answer came from, and what the client received.
        Every field is a fact recorded when the query was answered, so a group
        or blocklist renamed since keeps the name it had.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: The query.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QueryDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/queries/live:
    get:
      summary: Live query stream (server-sent events)
      description: |
        `text/event-stream`. The stream opens with `retry: 3000`, then sends
        one `event: query` frame per resolved query. The `data:` line is one
        query fully explained, in the order a query meets the pipeline: the six
        objects `QueryDetail` documents — `request`, `group`, `policy`,
        `rewrites`, `route`, `response` — all of them required, and without
        that schema's `id`, which does not exist yet because the entry precedes
        its own insert.
        A `: ping` comment goes out every 15 seconds.
        A client that falls more than 64 events behind is disconnected and
        should re-sync via `/api/queries` after reconnecting. Connections
        per address are capped by `web.sse_max_connections_per_ip`; the
        stream does not spend rate-limit tokens.
      responses:
        "200":
          description: An event stream; stays open until either side closes.
          content:
            text/event-stream:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          description: This address already holds its maximum number of live streams.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/diagnostics:
    get:
      summary: Operational event log
      description: |
        Failure episodes, newest first. One event is one subject failing
        continuously: it opens on the first failure, counts repeats in
        `occurrences`, and gets a `resolved_at` when the subject recovers. A
        subject that fails again opens a new event rather than reopening the
        old one. Keyset pagination — follow `next_before` until it is null.
      parameters:
        - name: state
          in: query
          schema: { type: string, enum: [active, resolved, all], default: all }
        - name: severity
          in: query
          schema: { type: string, enum: [warning, error] }
        - name: component
          in: query
          description: Matches the part of `code` before the dot, exactly.
          schema: { type: string, maxLength: 64 }
        - name: since
          in: query
          description: |
            Unix seconds. With `until`, selects episodes overlapping the
            window; an episode resolved exactly at `since` does not overlap.
          schema: { type: integer }
        - name: until
          in: query
          description: Unix seconds, exclusive.
          schema: { type: integer }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
        - name: before
          in: query
          description: Return events with id strictly below this cursor.
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: One page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiagnosticsPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Purge every resolved event
      description: |
        Deletes the resolved history and answers with how many rows went.
        Active events are never touched, so clearing the page cannot lose an
        episode that is still failing. Resolution stays automatic; this only
        decides when the history disappears. A runtime action, served in file
        mode too — the event log is not configuration.
      responses:
        "200":
          description: How many resolved events were removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiagnosticsPurge"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/diagnostics/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: One operational event
      description: |
        404 for an id that never existed and for one retention has removed —
        the API does not distinguish them.
      responses:
        "200":
          description: The event.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiagnosticEvent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Purge one resolved event
      description: |
        Deletes a resolved event. An event that is still active answers 409 —
        an open episode is the current state of the box, not history — and an
        id no row holds answers 404. A runtime action, served in file mode too.
      responses:
        "204":
          description: Purged.
        "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"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/stats:
    get:
      summary: Totals for a period
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: Totals over the period's UTC-aligned window.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatsTotals"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/stats/timeseries:
    get:
      summary: Bucketed counts for a period
      description: |
        Fixed-width UTC buckets covering the same window `/api/stats`
        reports for the period: 1h into 60 one-minute buckets, 24h into 48
        half-hour buckets, 7d into 168 one-hour buckets, 30d into 120
        six-hour buckets. Empty buckets are zero-filled.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: The bucket series.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatsTimeseries"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/stats/types:
    get:
      summary: Query-type breakdown for a period
      description: |
        How many queries of each DNS type the period's window holds, over the
        same UTC-aligned window `/api/stats` reports for. Rows carry the numeric
        type only: the type-name table lives in the admin, and a second copy
        here would drift out of agreement with it. `qtype` is nullable in the
        query log, so the rows that carry no type group into a row of their own
        rather than vanishing from a breakdown that claims to add up. Ordered by
        count descending, then type ascending with the null row last. Types
        absent from the window are absent from the list.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: The type breakdown.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatsTypes"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/stats/routes:
    get:
      summary: How the period's queries were answered
      description: |
        A breakdown by answering route over the same window `/api/stats`
        reports for. `source` is the answering resolver's identity — the
        upstream url on `upstream` rows, the zone on `forward_zone` rows, null
        on every other kind and on rows whose identity the log did not record.
        It is not the blocklist a block came from. Ordered by count descending,
        then route ascending, then source ascending with nulls last.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: The route breakdown.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatsRoutes"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/stats/clients:
    get:
      summary: Per-client bucketed counts for a period
      description: |
        One zero-filled series per client, bucketed exactly like
        `/api/stats/timeseries` so the two charts share an x-axis. The eight
        clients with the most queries in the window are named, ranked by count
        descending then address ascending; every other client sums into
        `other`, which is always present and always holds one entry per bucket
        in the window — including when `clients` is empty, when no client fell
        outside the named eight, and when the window holds no queries at all.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: The per-client series.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatsClients"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/lookup:
    get:
      summary: Explain a domain
      description: |
        Runs the full pipeline view for a domain without answering DNS:
        local records, forward zone, block decision with reason and matched
        pattern, source URL when a blocklist matched, and any safe-search
        rewrite.
      parameters:
        - name: domain
          in: query
          required: true
          schema: { type: string, maxLength: 253 }
        - name: group_id
          in: query
          description: Group row id; the default group when absent.
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: The pipeline's view of the domain.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Lookup"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/groups:
    get:
      summary: List groups
      responses:
        "200":
          description: Every group.
          content:
            application/json:
              schema:
                type: object
                required: [groups]
                properties:
                  groups:
                    type: array
                    items:
                      $ref: "#/components/schemas/Group"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Create a group
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GroupInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Group"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/groups/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a group
      responses:
        "200":
          description: The group.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Group"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a group
      description: The `default` group cannot be renamed (409).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GroupInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Group"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a group
      description: |
        The `default` group cannot be deleted, and neither can a group that
        still has clients (409 either way).
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/groups/{id}/sources:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Blocklist sources assigned to a group
      responses:
        "200":
          description: The assignment.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GroupSources"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Replace the assignment
      description: Idempotent full-set replace; duplicates collapse.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GroupSources"
      responses:
        "200":
          description: Replaced; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GroupSources"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/blocklists:
    get:
      summary: List blocklist sources
      responses:
        "200":
          description: Every source with its load counters.
          content:
            application/json:
              schema:
                type: object
                required: [blocklists]
                properties:
                  blocklists:
                    type: array
                    items:
                      $ref: "#/components/schemas/Blocklist"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Add a blocklist source
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BlocklistInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BlocklistEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/blocklists/update:
    post:
      summary: Refresh every enabled source now
      description: |
        Runs a fetch-and-compile pass over all enabled sources, reloads the
        filter, and reports the per-source status snapshot.
      responses:
        "202":
          description: Refresh ran; the snapshot reflects it.
          content:
            application/json:
              schema:
                type: object
                required: [sources]
                properties:
                  sources:
                    type: array
                    items:
                      $ref: "#/components/schemas/SourceStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/blocklists/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a blocklist source
      responses:
        "200":
          description: The source with its load counters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Blocklist"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a blocklist source
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BlocklistInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BlocklistEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a blocklist source
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/rules:
    get:
      summary: List rules
      responses:
        "200":
          description: Every allow/block rule.
          content:
            application/json:
              schema:
                type: object
                required: [rules]
                properties:
                  rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/Rule"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Create a rule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RuleInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuleEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/rules/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a rule
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Rule"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a rule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RuleInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuleEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a rule
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/local-records:
    get:
      summary: List local DNS records
      responses:
        "200":
          description: Every local record.
          content:
            application/json:
              schema:
                type: object
                required: [local_records]
                properties:
                  local_records:
                    type: array
                    items:
                      $ref: "#/components/schemas/LocalRecord"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Create a local record
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LocalRecordInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LocalRecord"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/local-records/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a local record
      responses:
        "200":
          description: The record.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LocalRecord"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a local record
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LocalRecordInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LocalRecord"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a local record
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/forward-zones:
    get:
      summary: List forward zones
      responses:
        "200":
          description: Every forward zone.
          content:
            application/json:
              schema:
                type: object
                required: [forward_zones]
                properties:
                  forward_zones:
                    type: array
                    items:
                      $ref: "#/components/schemas/ForwardZone"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Create a forward zone
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ForwardZoneInput"
      responses:
        "201":
          description: Created; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForwardZone"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/forward-zones/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a forward zone
      responses:
        "200":
          description: The zone.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForwardZone"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update a forward zone
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ForwardZoneInput"
      responses:
        "200":
          description: Updated; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForwardZone"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete a forward zone
      responses:
        "204":
          description: Deleted; applied live.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/clients:
    get:
      summary: List clients
      description: |
        Every known client, materialized ones included, each with
        `hand_edited`. Clients have no POST: rows appear through DNS
        activity or configuration import.
      responses:
        "200":
          description: Every client.
          content:
            application/json:
              schema:
                type: object
                required: [clients]
                properties:
                  clients:
                    type: array
                    items:
                      $ref: "#/components/schemas/Client"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/clients/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read a client
      responses:
        "200":
          description: The client.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Client"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Rename or regroup a client
      description: |
        Sets `hand_edited`. The address is the client's identity and cannot
        be changed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClientEdit"
      responses:
        "200":
          description: The updated row; applied live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Client"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Forget a client
      description: A live client re-materializes on its next query.
      responses:
        "204":
          description: Deleted; applied live.
        "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"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/client-prefixes:
    get:
      summary: List client prefixes
      responses:
        "200":
          description: The whole prefix table.
          content:
            application/json:
              schema:
                type: object
                required: [client_prefixes]
                properties:
                  client_prefixes:
                    type: array
                    items:
                      $ref: "#/components/schemas/ClientPrefix"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Replace the prefix table
      description: |
        Atomic whole-list replace; an empty array clears the table. A prefix
        listed twice is a 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_prefixes]
              properties:
                client_prefixes:
                  type: array
                  items:
                    $ref: "#/components/schemas/ClientPrefixInput"
      responses:
        "200":
          description: The stored table, re-read; applied live.
          content:
            application/json:
              schema:
                type: object
                required: [client_prefixes]
                properties:
                  client_prefixes:
                    type: array
                    items:
                      $ref: "#/components/schemas/ClientPrefix"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/upstreams:
    get:
      summary: List upstream resolvers
      responses:
        "200":
          description: Every configured upstream.
          content:
            application/json:
              schema:
                type: object
                required: [upstreams]
                properties:
                  upstreams:
                    type: array
                    items:
                      $ref: "#/components/schemas/Upstream"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Add an upstream
      description: Restart-required; the running pool is not changed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpstreamInput"
      responses:
        "201":
          description: Created; takes effect on restart.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpstreamEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/upstreams/{id}:
    parameters:
      - $ref: "#/components/parameters/RowId"
    get:
      summary: Read an upstream
      responses:
        "200":
          description: The upstream.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Upstream"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update an upstream
      description: Restart-required; the running pool is not changed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpstreamInput"
      responses:
        "200":
          description: Updated; takes effect on restart.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpstreamEcho"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      summary: Delete an upstream
      description: The last enabled upstream cannot be removed (409).
      responses:
        "204":
          description: Deleted; takes effect on restart.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/pause:
    get:
      summary: Read the pause state
      responses:
        "200":
          description: |
            `until` is null while unpaused and while paused indefinitely;
            `paused` disambiguates.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pause"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      summary: Pause or resume blocking
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PauseRequest"
      responses:
        "200":
          description: The state after applying.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pause"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/settings:
    get:
      summary: Read the scalar settings
      description: |
        Every stored settings key, plus the derived `web.auth_enabled`.
        `restart_required` lists every key, because all scalar settings are
        restart-required this milestone; live behavior comes from the
        resource endpoints and `/api/pause`. Passwords and hashes are never
        serialized.
      responses:
        "200":
          description: The settings and the restart-required key list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SettingsEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"
    put:
      summary: Update settings
      description: |
        Partial update: omitted sections and fields keep their stored
        values. The merged configuration is validated before anything is
        written; on any failure nothing changes. A non-empty `web.password`
        is hashed with argon2id and stored as the hash; changing it clears
        every session. `web.password_hash` itself is not accepted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SettingsPatch"
      responses:
        "200":
          description: The merged settings, as `GET` would report them.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SettingsEnvelope"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ManagedByFile"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/Internal"
        "503":
          $ref: "#/components/responses/Unavailable"

  /api/config/status:
    get:
      summary: Read the configuration authority and restart state
      description: |
        Which source governs this process's configuration, and whether a
        change already written waits for a restart. Both are per-process
        facts the database cannot answer, and this is their one home: a
        client reads the mode here rather than discovering it from a 403.
      responses:
        "200":
          description: The live authority and the pending-restart flag.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConfigStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/certs/reload:
    post:
      summary: Reload the TLS certificates from disk
      description: |
        Reloads the certificate and key of every enabled DoH/DoT endpoint.
        Always answers 200: the per-endpoint outcome is the payload, and a
        failed reload leaves the previous certificate serving.
      responses:
        "200":
          description: The outcome for each endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CertsReload"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: nxdns_session
      description: |
        Minted by `POST /api/auth/login`. Only meaningful when
        `web.password_hash` is configured; without it, every endpoint is
        open and the cookie is ignored.

  parameters:
    RowId:
      name: id
      in: path
      required: true
      description: Positive integer row id.
      schema:
        type: integer
        minimum: 1
    Period:
      name: period
      in: query
      description: Statistics window; the default is `24h`.
      schema:
        type: string
        enum: [1h, 24h, 7d, 30d]
        default: 24h

  responses:
    BadRequest:
      description: Malformed path, query parameter or body; validation failure.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: |
        Authentication is enabled and the request carried no valid session
        cookie.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    ManagedByFile:
      description: |
        nxdns is running under file authority and this operation writes
        configuration. The message names the file. Authentication is checked
        first, so an unauthenticated request to a protected route still
        answers 401 rather than disclosing that the route exists.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: No row has this id.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: |
        The change contradicts stored data: a duplicate key, a reference to
        a missing row, or a delete the data still depends on.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BodyTooLarge:
      description: The request body exceeds 1 MiB.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: |
        The per-address token bucket is empty. `Retry-After` says when one
        token will be available.
      headers:
        Retry-After:
          schema:
            type: integer
            minimum: 1
          description: Seconds until a retry can succeed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Internal:
      description: |
        Something on the server failed; the detail is in the server log,
        never on the wire.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unavailable:
      description: |
        A collaborator this endpoint needs is not running: no configuration
        database, no query log, no snapshot loaded, or the server is
        shutting down.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Operator-facing message.

    Health:
      type: object
      required: [status, protection, upstreams, query_history, diagnostics, disk]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        protection:
          $ref: "#/components/schemas/HealthProtection"
        upstreams:
          $ref: "#/components/schemas/HealthUpstreams"
        query_history:
          $ref: "#/components/schemas/HealthQueryHistory"
        diagnostics:
          $ref: "#/components/schemas/HealthDiagnostics"
        disk:
          $ref: "#/components/schemas/HealthDisk"

    HealthProtection:
      type: object
      required: [state, until]
      properties:
        state:
          type: string
          enum: [active, paused, unavailable]
          description: >
            unavailable when no filter snapshot exists for the query path to
            evaluate against, which outranks any pause and is the only one of
            the three that degrades health. An expired timed pause is active.
        until:
          type: integer
          nullable: true
          description: >
            The second filtering resumes at. Null for an indefinite pause and
            for every state other than paused.

    HealthUpstreams:
      type: object
      required: [state, available, total]
      properties:
        state:
          type: string
          enum: [ok, unavailable]
          description: unavailable exactly when `available` is 0; that degrades health.
        available: { type: integer }
        total:
          type: integer
          description: Enabled upstreams, which is what the routing pool is built from.

    HealthQueryHistory:
      type: object
      required: [state, dropped_total, last_drop_s]
      properties:
        state:
          type: string
          enum: [recording, losing, failed]
          description: >
            failed when the query-log writer never started; losing while the
            disk gate is holding writes back and has already cost rows in the
            episode open now. Both degrade health. Drops from an earlier
            episode do not change the state - they are reported by the two
            fields below.
        dropped_total:
          type: integer
          description: Query rows lost since this process started, cumulative.
        last_drop_s:
          type: integer
          nullable: true
          description: >
            The newest drop, unix seconds; null until one happens. Stamped by a
            separate atomic from the count, so a non-zero `dropped_total` beside
            a null here is a legal momentary answer.

    HealthDiagnostics:
      type: object
      required: [state, active_warnings, active_errors]
      properties:
        state:
          type: string
          enum: [recording, unavailable]
          description: unavailable when the event store failed to open or its writes are failing; either state degrades health.
        active_warnings: { type: integer }
        active_errors: { type: integer }

    HealthDisk:
      type: object
      required: [state, free_bytes]
      properties:
        state:
          type: string
          enum: [ok, low, critical]
          description: >
            The disk monitor's own states; its `warn` is renamed `low` here,
            because `warn` reads as a log level rather than as a quantity of
            disk. Both `low` and `critical` degrade health.
        free_bytes: { type: integer }

    Version:
      type: object
      required: [version, git_commit, zig_version, uptime_seconds]
      properties:
        version: { type: string }
        git_commit: { type: string }
        zig_version: { type: string }
        uptime_seconds: { type: integer }

    LoginRequest:
      type: object
      required: [password]
      properties:
        password: { type: string }

    LoginResponse:
      type: object
      required: [authenticated, auth_required]
      properties:
        authenticated:
          type: boolean
          enum: [true]
        auth_required:
          type: boolean
          description: False when no password is configured; no cookie is set.

    PolicyAction:
      type: string
      description: |
        Whether the filtering policy reached a verdict. `not_evaluated` is the
        honest answer for a query answered before filtering could apply, and is
        not the same as `allow`.
      enum: [not_evaluated, allow, block]

    PolicyReason:
      type: string
      description: |
        Why the policy landed where it did. The first nine are the matcher's own
        verdicts; the rest name a pipeline step that decided without consulting
        the matcher.
      enum: [rule_allow_exact, rule_block_exact, rule_allow_wildcard, rule_block_wildcard, rule_allow_regex, rule_block_regex, blocklist_exception, blocklist_domain, blocklist_wildcard, local_record, forward_zone, non_in_class, paused, snapshot_unavailable, no_match, protocol_error]

    RouteKind:
      type: string
      description: Where the answer the client received came from.
      enum: [blocked, local, forward_zone, upstream, cache, rejected]

    QueryRow:
      type: object
      description: |
        The summary projection the query-log table scans. The full provenance of
        a row is one request away at `/api/queries/{id}`.
      required: [id, ts, domain, client_ip, qtype, qclass, rcode, blocked, response_time_us, cache_hit, upstream, policy_action, policy_reason, route_kind]
      properties:
        id: { type: integer }
        ts:
          type: integer
          description: Unix seconds.
        domain: { type: string }
        client_ip: { type: string }
        qtype:
          type: integer
          nullable: true
        qclass: { type: integer }
        rcode:
          type: integer
          description: The twelve-bit EDNS extended code, not the four header bits alone.
        blocked: { type: boolean }
        response_time_us:
          type: integer
          nullable: true
        cache_hit:
          type: boolean
          nullable: true
        upstream:
          type: string
          description: Empty for cache hits, local answers and blocked queries.
        policy_action:
          $ref: "#/components/schemas/PolicyAction"
        policy_reason:
          $ref: "#/components/schemas/PolicyReason"
        route_kind:
          $ref: "#/components/schemas/RouteKind"

    ProvenanceRequest:
      type: object
      required: [time, domain, client, qtype, qclass]
      properties:
        time:
          type: integer
          description: Unix seconds.
        domain: { type: string }
        client: { type: string }
        qtype:
          type: integer
          nullable: true
        qclass: { type: integer }

    ProvenanceGroup:
      type: object
      description: |
        The client's filtering group at the time of the query, as a historical
        fact: the id may name a group since renamed or deleted.
      required: [id, name]
      properties:
        id:
          type: integer
          nullable: true
        name: { type: string }

    ProvenancePolicy:
      type: object
      required: [action, reason, matched, source_id, source_name]
      properties:
        action:
          $ref: "#/components/schemas/PolicyAction"
        reason:
          $ref: "#/components/schemas/PolicyReason"
        matched:
          type: string
          description: The rule pattern or list entry that decided; empty when nothing matched.
        source_id:
          type: integer
          nullable: true
        source_name:
          type: string
          description: The blocklist the match came from; empty for a rule.

    ProvenanceRewrites:
      type: object
      required: [cname_target, safe_search_target]
      properties:
        cname_target:
          type: string
          description: Set when the decision was made about a CNAME target rather than the queried name.
        safe_search_target: { type: string }

    ProvenanceRoute:
      type: object
      required: [kind, forward_zone, upstream]
      properties:
        kind:
          $ref: "#/components/schemas/RouteKind"
        forward_zone: { type: string }
        upstream:
          type: string
          description: |
            Non-empty only for an attempted upstream or forward-zone exchange,
            including one that failed. Already redacted: the userinfo, path,
            query and fragment of a resolver url never reach here.

    ProvenanceResponse:
      type: object
      required: [rcode, duration_us]
      properties:
        rcode:
          type: integer
          description: The twelve-bit EDNS extended code, not the four header bits alone.
        duration_us:
          type: integer
          nullable: true

    QueryDetail:
      type: object
      description: |
        One query, fully explained, in the order a query meets the pipeline,
        plus the row id. Written out rather than composed with `allOf` so the
        drift guard reads one property list per schema.
      required: [id, request, group, policy, rewrites, route, response]
      properties:
        id: { type: integer }
        request:
          $ref: "#/components/schemas/ProvenanceRequest"
        group:
          $ref: "#/components/schemas/ProvenanceGroup"
        policy:
          $ref: "#/components/schemas/ProvenancePolicy"
        rewrites:
          $ref: "#/components/schemas/ProvenanceRewrites"
        route:
          $ref: "#/components/schemas/ProvenanceRoute"
        response:
          $ref: "#/components/schemas/ProvenanceResponse"

    Coverage:
      type: object
      description: |
        How much of the requested window the query log can still answer for.
        Retention deletes rows and advances the watermark in one transaction, so
        a client can tell an empty window from a pruned one instead of charting
        the gap as zero.
      required: [complete, available_since]
      properties:
        complete:
          type: boolean
          description: |
            True only when the window's lower bound is at or after
            `available_since`. A request with no lower bound asks about all of
            history, which no file that has ever pruned can promise.
        available_since:
          type: integer
          description: The oldest instant the file is complete for, unix seconds.

    QueriesPage:
      type: object
      required: [queries, next_before, coverage]
      properties:
        queries:
          type: array
          items:
            $ref: "#/components/schemas/QueryRow"
        next_before:
          type: integer
          nullable: true
          description: Cursor for the next page; null on the last page.
        coverage:
          $ref: "#/components/schemas/Coverage"

    DiagnosticEvent:
      type: object
      required: [id, code, component, subject, severity, first_seen, last_seen, occurrences, resolved_at, detail]
      properties:
        id: { type: integer }
        code:
          type: string
          description: |
            The failure kind, as `component.name`. One of a fixed set of
            fifteen; new codes are added with new releases.
          enum:
            - disk.space
            - disk.probe
            - blocklist.refresh
            - blocklist.snapshot
            - blocklist.storage
            - certificate.reload
            - query_log.write
            - query_log.maintenance
            - query_log.recreated
            # Legacy: nothing emits this any more (milestone 30 deleted the
            # upstream-minute history subsystem), but stored rows survive and
            # the list endpoint passes their code through.
            - upstream_history.write
            - upstream.exchange
            - client_names.storage
            - clients.storage
            - listener.start
            - configuration.load
        component:
          type: string
          description: The part of `code` before the dot, repeated for filtering.
        subject:
          type: string
          description: |
            What failed, as a display name: a blocklist source name, an
            endpoint, an operation. Redacted where it derives from a url; the
            store's internal identity for the subject is never exposed.
        severity:
          type: string
          enum: [warning, error]
        first_seen:
          type: integer
          description: When this episode opened, unix seconds.
        last_seen:
          type: integer
          description: The most recent failure of this episode, unix seconds.
        occurrences:
          type: integer
          description: How many failures this episode has held; at least 1.
        resolved_at:
          type: integer
          nullable: true
          description: |
            When the subject recovered, unix seconds. Null while the episode is
            still open. A subject that fails again opens a new event rather than
            reopening this one.
        detail:
          type: string
          description: The last error of this episode, truncated to 512 bytes.

    DiagnosticsPage:
      type: object
      required: [events, next_before, active]
      properties:
        events:
          type: array
          items:
            $ref: "#/components/schemas/DiagnosticEvent"
        next_before:
          type: integer
          nullable: true
          description: Cursor for the next page; null on the last page.
        active:
          type: object
          required: [warnings, errors]
          description: Episodes open right now, whatever this page filtered to.
          properties:
            warnings: { type: integer }
            errors: { type: integer }

    DiagnosticsPurge:
      type: object
      required: [purged]
      properties:
        purged:
          type: integer
          description: How many resolved events the purge removed; zero when there were none.

    StatsTotals:
      type: object
      required: [period, since, until, queries, blocked, clients, avg_response_time_us, coverage]
      properties:
        period:
          type: string
          enum: [1h, 24h, 7d, 30d]
        since:
          type: integer
          description: Window start, unix seconds, inclusive.
        until:
          type: integer
          description: Window end, unix seconds, exclusive.
        queries: { type: integer }
        blocked: { type: integer }
        clients:
          type: integer
          description: Distinct client addresses in the window.
        avg_response_time_us:
          type: integer
          nullable: true
          description: Null when no query in the window recorded a time.
        coverage:
          $ref: "#/components/schemas/Coverage"

    Bucket:
      type: object
      required: [ts, queries, blocked, cached]
      properties:
        ts:
          type: integer
          description: Bucket start, unix seconds.
        queries: { type: integer }
        blocked: { type: integer }
        cached: { type: integer }

    StatsTimeseries:
      type: object
      required: [period, since, until, bucket_seconds, buckets, coverage]
      properties:
        period:
          type: string
          enum: [1h, 24h, 7d, 30d]
        since: { type: integer }
        until: { type: integer }
        bucket_seconds: { type: integer }
        buckets:
          type: array
          items:
            $ref: "#/components/schemas/Bucket"
        coverage:
          $ref: "#/components/schemas/Coverage"

    TypeCount:
      type: object
      required: [qtype, count]
      properties:
        qtype:
          type: integer
          nullable: true
          description: |
            The numeric DNS type. Null is the group of logged queries that
            recorded no type, not an absent row.
        count: { type: integer }

    StatsTypes:
      type: object
      required: [period, since, until, types, coverage]
      properties:
        period:
          type: string
          enum: [1h, 24h, 7d, 30d]
        since: { type: integer }
        until: { type: integer }
        types:
          type: array
          items:
            $ref: "#/components/schemas/TypeCount"
        coverage:
          $ref: "#/components/schemas/Coverage"

    RouteCount:
      type: object
      required: [route, source, count]
      properties:
        route:
          $ref: "#/components/schemas/RouteKind"
        source:
          type: string
          nullable: true
          description: |
            The answering upstream's url or the forward zone, and null on every
            other route kind. Also null when an `upstream` or `forward_zone`
            row recorded no identity.
        count: { type: integer }

    StatsRoutes:
      type: object
      required: [period, since, until, routes, coverage]
      properties:
        period:
          type: string
          enum: [1h, 24h, 7d, 30d]
        since: { type: integer }
        until: { type: integer }
        routes:
          type: array
          items:
            $ref: "#/components/schemas/RouteCount"
        coverage:
          $ref: "#/components/schemas/Coverage"

    ClientSeries:
      type: object
      required: [client, buckets]
      properties:
        client:
          type: string
          description: The client address as the log recorded it, redaction included.
        buckets:
          type: array
          description: |
            One count per bucket in the window, zero-filled. Every series in a
            response has this same length, `other` included.
          items:
            type: integer

    StatsClients:
      type: object
      required: [period, since, until, bucket_seconds, clients, other, coverage]
      properties:
        period:
          type: string
          enum: [1h, 24h, 7d, 30d]
        since: { type: integer }
        until: { type: integer }
        bucket_seconds: { type: integer }
        clients:
          type: array
          items:
            $ref: "#/components/schemas/ClientSeries"
        other:
          type: array
          description: |
            Every client outside the named eight, summed per bucket. Always
            present, and always one entry per bucket in the window — including
            when `clients` is empty, when no client fell outside the named
            eight, and when the window holds no queries at all.
          items:
            type: integer
        coverage:
          $ref: "#/components/schemas/Coverage"

    Lookup:
      type: object
      required: [domain, group_id, local_records, forward_zone, blocked, reason, matched, source_url, safe_search_rewrite]
      properties:
        domain:
          type: string
          description: The normalized form of the queried domain.
        group_id: { type: integer }
        local_records:
          type: boolean
          description: Whether a local record answers this name.
        forward_zone:
          type: string
          nullable: true
        blocked: { type: boolean }
        reason:
          type: string
          description: The pipeline's decision, e.g. `none` or `blocklist_domain`.
        matched:
          type: string
          description: The matched pattern; empty when nothing matched.
        source_url:
          type: string
          nullable: true
        safe_search_rewrite:
          type: string
          nullable: true

    Group:
      type: object
      required: [id, name, safe_search]
      properties:
        id: { type: integer }
        name: { type: string }
        safe_search: { type: boolean }

    GroupInput:
      type: object
      required: [name]
      properties:
        name: { type: string }
        safe_search:
          type: boolean
          default: false

    GroupSources:
      type: object
      required: [source_ids]
      properties:
        source_ids:
          type: array
          items: { type: integer }

    Blocklist:
      type: object
      required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, exception_count, skipped_regex_count, skipped_unsupported_count, checksum]
      properties:
        id: { type: integer }
        url: { type: string }
        name: { type: string }
        enabled: { type: boolean }
        is_suggested: { type: boolean }
        last_updated:
          type: integer
          nullable: true
        domain_count: { type: integer }
        wildcard_count: { type: integer }
        exception_count: { type: integer }
        skipped_regex_count: { type: integer }
        skipped_unsupported_count: { type: integer }
        checksum:
          type: string
          nullable: true

    BlocklistInput:
      type: object
      required: [url, name]
      properties:
        url: { type: string }
        name: { type: string }
        enabled:
          type: boolean
          default: true
        is_suggested:
          type: boolean
          default: false

    BlocklistEcho:
      type: object
      required: [id, url, name, enabled, is_suggested]
      properties:
        id: { type: integer }
        url: { type: string }
        name: { type: string }
        enabled: { type: boolean }
        is_suggested: { type: boolean }

    SourceStatus:
      type: object
      required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, exceptions, skipped_regex, skipped_unsupported]
      properties:
        id: { type: integer }
        state:
          type: string
          description: The refresh outcome, e.g. `loaded` or `fetch_failed`.
        loaded: { type: boolean }
        last_attempt: { type: integer }
        last_success: { type: integer }
        url: { type: string }
        last_error:
          type: string
          description: Empty when the last attempt succeeded.
        domains: { type: integer }
        wildcards: { type: integer }
        exceptions: { type: integer }
        skipped_regex: { type: integer }
        skipped_unsupported: { type: integer }

    Rule:
      type: object
      required: [id, group_id, group, pattern, kind, action, created_at]
      properties:
        id: { type: integer }
        group_id: { type: integer }
        group: { type: string }
        pattern: { type: string }
        kind:
          type: string
          enum: [exact, wildcard, regex]
        action:
          type: string
          enum: [allow, block]
        created_at: { type: integer }

    RuleInput:
      type: object
      required: [group_id, pattern, kind, action]
      properties:
        group_id: { type: integer }
        pattern: { type: string }
        kind:
          type: string
          enum: [exact, wildcard, regex]
        action:
          type: string
          enum: [allow, block]

    RuleEcho:
      type: object
      required: [id, group_id, pattern, kind, action]
      properties:
        id: { type: integer }
        group_id: { type: integer }
        pattern: { type: string }
        kind:
          type: string
          enum: [exact, wildcard, regex]
        action:
          type: string
          enum: [allow, block]

    LocalRecord:
      type: object
      required: [id, name, rtype, value, ttl]
      properties:
        id: { type: integer }
        name: { type: string }
        rtype:
          type: string
          enum: [A, AAAA, CNAME]
        value: { type: string }
        ttl: { type: integer }

    LocalRecordInput:
      type: object
      required: [name, rtype, value]
      properties:
        name: { type: string }
        rtype:
          type: string
          enum: [A, AAAA, CNAME]
        value: { type: string }
        ttl:
          type: integer
          default: 300

    ForwardZone:
      type: object
      required: [id, zone, resolver]
      properties:
        id: { type: integer }
        zone: { type: string }
        resolver: { type: string }

    ForwardZoneInput:
      type: object
      required: [zone, resolver]
      properties:
        zone: { type: string }
        resolver: { type: string }

    Client:
      type: object
      required: [id, ip, name, learned_name, group_id, group, hand_edited, first_seen, last_seen]
      properties:
        id: { type: integer }
        ip: { type: string }
        name:
          type: string
          description: Empty when the client was never named.
        learned_name:
          type: string
          description: |
            The name learned over reverse DNS, or empty when nothing was
            learned. Display-only runtime state: `name` wins whenever it is
            non-empty, and a learned name never appears in an export. The
            server writes it; a client cannot.
        group_id: { type: integer }
        group: { type: string }
        hand_edited: { type: boolean }
        first_seen: { type: integer }
        last_seen: { type: integer }

    ClientEdit:
      type: object
      required: [group_id]
      properties:
        name:
          type: string
          default: ""
        group_id: { type: integer }

    ClientPrefix:
      type: object
      required: [id, prefix, group_id, group, priority]
      properties:
        id: { type: integer }
        prefix: { type: string }
        group_id: { type: integer }
        group: { type: string }
        priority: { type: integer }

    ClientPrefixInput:
      type: object
      required: [prefix, group_id]
      properties:
        prefix: { type: string }
        group_id: { type: integer }
        priority:
          type: integer
          default: 100

    Upstream:
      type: object
      required: [id, url, priority, enabled, tls_name]
      properties:
        id: { type: integer }
        url: { type: string }
        priority: { type: integer }
        enabled: { type: boolean }
        tls_name:
          type: string
          description: Only meaningful for DoT upstreams; empty otherwise.

    UpstreamInput:
      type: object
      required: [url]
      properties:
        url: { type: string }
        priority:
          type: integer
          default: 100
        enabled:
          type: boolean
          default: true
        tls_name:
          type: string
          default: ""

    UpstreamEcho:
      type: object
      required: [id, url, priority, enabled, tls_name, restart_required]
      properties:
        id: { type: integer }
        url: { type: string }
        priority: { type: integer }
        enabled: { type: boolean }
        tls_name: { type: string }
        restart_required:
          type: boolean
          enum: [true]

    Pause:
      type: object
      required: [paused, until]
      properties:
        paused: { type: boolean }
        until:
          type: integer
          nullable: true
          description: |
            Unix second filtering resumes; null while unpaused and while
            paused indefinitely.

    PauseRequest:
      type: object
      required: [paused]
      properties:
        paused: { type: boolean }
        duration_seconds:
          type: integer
          nullable: true
          minimum: 1
          maximum: 604800
          description: Only meaningful with `paused = true`; absent means indefinite.

    CertReloadOutcome:
      type: object
      required: [enabled, reloaded, error]
      properties:
        enabled:
          type: boolean
          description: Whether the endpoint is enabled in the configuration.
        reloaded:
          type: boolean
        error:
          type: string
          nullable: true
          description: Why the reload failed; null on success and while disabled.

    CertsReload:
      type: object
      required: [doh, dot]
      properties:
        doh:
          $ref: "#/components/schemas/CertReloadOutcome"
        dot:
          $ref: "#/components/schemas/CertReloadOutcome"

    Settings:
      type: object
      required: [upstream, dns, blocking, cache, web, doh_server, dot_server, edns, logging, disk, blocklist_update]
      properties:
        upstream:
          type: object
          required: [attempt_timeout_ms, read_timeout_ms, total_timeout_ms]
          properties:
            attempt_timeout_ms: { type: integer }
            read_timeout_ms: { type: integer }
            total_timeout_ms: { type: integer }
        dns:
          type: object
          required: [bind_ipv4, bind_ipv6, port, rate_limit, rate_window_seconds]
          properties:
            bind_ipv4: { type: string }
            bind_ipv6: { type: string }
            port: { type: integer }
            rate_limit: { type: integer }
            rate_window_seconds: { type: integer }
        blocking:
          type: object
          required: [response, ttl]
          properties:
            response:
              type: string
              enum: [zero, nxdomain]
            ttl: { type: integer }
        cache:
          type: object
          required: [size, negative_ttl_max]
          properties:
            size: { type: integer }
            negative_ttl_max: { type: integer }
        web:
          type: object
          required: [enabled, bind, port, session_ttl_hours, api_rate_limit_per_min, api_localhost_exempt, sse_max_connections_per_ip, trusted_proxies, auth_enabled]
          properties:
            enabled: { type: boolean }
            bind: { type: string }
            port: { type: integer }
            session_ttl_hours: { type: integer }
            api_rate_limit_per_min: { type: integer }
            api_localhost_exempt: { type: boolean }
            sse_max_connections_per_ip: { type: integer }
            trusted_proxies:
              type: string
              description: |
                Comma-separated IP literals. A request from one of these peers
                is identified by the last entry of its X-Forwarded-For header.
                Empty trusts no proxy.
            auth_enabled:
              type: boolean
              description: Derived, read-only; true iff a password hash is stored.
        doh_server:
          $ref: "#/components/schemas/TlsListenerSettings"
        dot_server:
          $ref: "#/components/schemas/TlsListenerSettings"
        edns:
          type: object
          required: [ecs_mode]
          properties:
            ecs_mode:
              type: string
              enum: [strip, forward]
        logging:
          type: object
          required: [level, retention_days, query_log_buffer_max, query_log_flush_interval_s, hide_domains, hide_client_ips, output, file_path, max_size_mb, max_files]
          properties:
            level:
              type: string
              enum: [error, warn, info, debug]
            retention_days: { type: integer }
            query_log_buffer_max: { type: integer }
            query_log_flush_interval_s: { type: integer }
            hide_domains: { type: boolean }
            hide_client_ips: { type: boolean }
            output:
              type: string
              enum: [stderr, syslog, file]
            file_path: { type: string }
            max_size_mb: { type: integer }
            max_files: { type: integer }
        disk:
          type: object
          required: [min_free_mb, warn_free_mb]
          properties:
            min_free_mb: { type: integer }
            warn_free_mb: { type: integer }
        blocklist_update:
          type: object
          required: [enabled, interval_hours]
          properties:
            enabled: { type: boolean }
            interval_hours: { type: integer }

    TlsListenerSettings:
      type: object
      required: [enabled, bind, port, cert_path, key_path]
      properties:
        enabled: { type: boolean }
        bind: { type: string }
        port: { type: integer }
        cert_path: { type: string }
        key_path: { type: string }

    SettingsEnvelope:
      type: object
      required: [settings, restart_required]
      properties:
        settings:
          $ref: "#/components/schemas/Settings"
        restart_required:
          type: array
          items: { type: string }
          description: |
            Every `section.field` key that needs a restart to take effect —
            currently all of them. Whether a restart is *owed* right now is
            process state, and lives on `/api/config/status`.

    ConfigStatus:
      type: object
      description: |
        Which source governs this process's configuration, and whether a
        committed change waits for a restart. This is how a client learns
        that configuration is read-only; it never has to probe a write route
        for a 403. It rides an authenticated endpoint because `path` is a
        filesystem path, and never appears on the open `/api/version` or
        `/api/health`.
      required: [authority, path, reconciled_at, restart_pending]
      properties:
        authority:
          type: string
          enum: [database, managed_file]
          description: |
            `database` when nxdns runs without `--config`; `managed_file`
            when it runs with it, in which case every configuration write
            answers 403.
        path:
          type: string
          nullable: true
          description: The managed file, or null in `database` mode.
        reconciled_at:
          type: integer
          nullable: true
          description: |
            When this process loaded the managed file, in epoch seconds, and
            null in `database` mode. It means exactly that: a file whose
            mtime is newer has not been loaded by the running process. It
            cannot answer whether the file matches what the server serves —
            a stepped clock or a preserved mtime defeats the comparison
            either way, and `nxdns import` can move the database without
            moving either timestamp.
        restart_pending:
          type: boolean
          description: |
            True once this process has committed a configuration change that
            takes effect only at the next start — an upstream write or a
            settings key. Nothing clears it but process exit, and it is
            never persisted, so a false after a restart is the truth.

    SettingsPatch:
      type: object
      description: |
        Partial update; every section and every field is optional. The
        shape is `Settings` without the derived `web.auth_enabled`, plus
        the write-only `web.password`. `web.password_hash` is rejected as
        an unknown field.
      properties:
        upstream:
          type: object
          properties:
            attempt_timeout_ms: { type: integer }
            read_timeout_ms: { type: integer }
            total_timeout_ms: { type: integer }
        dns:
          type: object
          properties:
            bind_ipv4: { type: string }
            bind_ipv6: { type: string }
            port: { type: integer }
            rate_limit: { type: integer }
            rate_window_seconds: { type: integer }
        blocking:
          type: object
          properties:
            response: { type: string }
            ttl: { type: integer }
        cache:
          type: object
          properties:
            size: { type: integer }
            negative_ttl_max: { type: integer }
        web:
          type: object
          properties:
            enabled: { type: boolean }
            bind: { type: string }
            port: { type: integer }
            password:
              type: string
              description: |
                Write-only. Hashed with argon2id and stored as the hash;
                never echoed. An empty string is ignored, not a removal.
            session_ttl_hours: { type: integer }
            api_rate_limit_per_min: { type: integer }
            api_localhost_exempt: { type: boolean }
            sse_max_connections_per_ip: { type: integer }
            trusted_proxies: { type: string }
        doh_server:
          $ref: "#/components/schemas/TlsListenerPatch"
        dot_server:
          $ref: "#/components/schemas/TlsListenerPatch"
        edns:
          type: object
          properties:
            ecs_mode: { type: string }
        logging:
          type: object
          properties:
            level: { type: string }
            retention_days: { type: integer }
            query_log_buffer_max: { type: integer }
            query_log_flush_interval_s: { type: integer }
            hide_domains: { type: boolean }
            hide_client_ips: { type: boolean }
            output: { type: string }
            file_path: { type: string }
            max_size_mb: { type: integer }
            max_files: { type: integer }
        disk:
          type: object
          properties:
            min_free_mb: { type: integer }
            warn_free_mb: { type: integer }
        blocklist_update:
          type: object
          properties:
            enabled: { type: boolean }
            interval_hours: { type: integer }

    TlsListenerPatch:
      type: object
      properties:
        enabled: { type: boolean }
        bind: { type: string }
        port: { type: integer }
        cert_path: { type: string }
        key_path: { type: string }
