openapi: 3.1.0

info:
  title: NSIN API
  version: "1.0.0"
  summary: Public REST API for NSIN CDN, DNS and edge-security management.
  description: |
    The NSIN API lets you manage everything you can manage from the panel:
    domains, DNS records, edge rules (cache, WAF, redirects, rate limiting, …),
    TLS certificates, analytics, uptime and domain sharing.

    ## Authentication

    Every endpoint in this reference is authenticated with an **API key**.
    Create one in the panel under *Settings → API keys*. Keys are shown once at
    creation time and are prefixed `nsin_`.

    Send the key either way — both are equivalent:

    ```
    Authorization: Bearer nsin_xxxxxxxxxxxxxxxxxxxx
    ```
    ```
    X-Api-Key: nsin_xxxxxxxxxxxxxxxxxxxx
    ```

    ### Read-only keys

    A key marked read-only may only issue `GET`, `HEAD` and `OPTIONS` requests.
    Any other method returns `403` with `{"error": "read-only API key"}`,
    regardless of the endpoint.

    ### Inactive accounts

    If the account that owns the key is deactivated, every request made with
    that key is refused with `403` and
    `{"error": "account is inactive", "code": "account_suspended"}` — on any
    endpoint, reads included. It is deliberately `403` and not `401`: the
    credential is still valid, the account behind it is not, so rotating the key
    fixes nothing. Individual operations do not list this code.

    ### What API keys cannot do

    Some parts of the product are deliberately unreachable with a key, so that a
    leaked key can never take over the account or spend money. These return
    `403` for **every** key, including full-access ones:

    | Surface | Reason |
    |---|---|
    | `/users/**` | Profile, password, sessions and API-key management. A key cannot mint or revoke keys. |
    | `/auth/**` | Login, registration, OTP. |
    | `/billing/**` | Plan catalogue and billing settings. |
    | `/admin/**` | Administrative surface. |
    | `POST /wallet/topup` | Moves money. |
    | `POST /subscriptions/purchase`, `/switch`, `/auto-renew` | Removed for everyone. Subscriptions are per domain, so these three now answer `410` whatever the credential — a key just never gets that far, because the denylist answers `403` first. Use the `/domains/{domain}/subscriptions/...` routes from the panel. |
    | `POST /domains/{domain}/subscriptions/purchase`, `/switch`, `/auto-renew` | Moves money. |

    Reading subscription, feature, traffic-usage, invoice and wallet state *is*
    allowed — only the money-moving writes are blocked.

    ### If your account is managed by a reseller

    A second gate, entirely separate from the denylist above, sits in front of the
    routes in this section. It keys on the **account**, not on the credential: it
    runs after both authentication paths have resolved the user, so a managed
    client gets the identical `403` from a panel session and from an API key —
    there is no way around it by switching credential. The single predicate is
    whether the account has a provider at all. If you signed up with NSIN directly,
    nothing here applies to you; it also never fires for a reseller himself.

    A reseller's client owns his sites; his provider owns the money, the support
    relationship and the sharing graph. So for a managed client these return `403`
    with `code: "managed_by_reseller"`:

    | Surface | Why |
    |---|---|
    | `/wallet` and `/wallet/**` | The wallet is funded and owned by your provider. |
    | `/invoices`, `/invoices/{id}`, `/domains/{domain}/invoices/**` | Invoices are raised against your provider, who bills you out of band. |
    | `POST /domains/{domain}/subscriptions/purchase`, `/switch`, `/auto-renew` | Your provider buys and switches plans on your behalf. |
    | `/tickets` and `/tickets/**` | Support goes to your provider, not to NSIN. |
    | `POST /domains/` | Your provider provisions domains, because their traffic burns his wallet. Managing a domain you already have is untouched. |
    | `/domains/{domain}/invites/**`, `/invites/{token}`, `/invites/{token}/accept` | You cannot sub-grant access to a domain your provider funds, or accept an invitation into one. |
    | `/domains/{domain}/members/{userId}` — every method except `GET` | Reading the member list is allowed on purpose; changing it is not. |

    Two traps in that table. `GET /domains/{domain}/members` stays open — your
    provider appears in it as an `admin` and you are entitled to see who holds
    rights on your domain — but the member `PATCH` is blocked wholesale, so a
    managed client cannot change even his *own* notification preferences through
    it. And key management is blocked for him too, so a managed client cannot mint
    an API key in the first place: the keys that hit these `403`s are ones minted
    before the account moved under a provider.

    Everything else is untouched — domains, DNS, rules, SSL, cache, analytics,
    uptime. Plan and subscription **reads** stay open so you can see which plan a
    domain is on and what it includes, but every price field is stripped from those
    responses on the way out. NSIN does not know what your provider charges you,
    and NSIN's own retail price is not the number you pay. Read a missing `price`
    as *not disclosed*, never as free.

    ## Rate limiting

    Requests are limited **per key**, by default to 300 requests per minute.
    Exceeding it returns `429` with `{"error": "rate limit exceeded"}`.
    Panel (browser) traffic is limited separately and does not consume your key's
    budget.

    ## Conventions

    * **`{domain}` path parameter** — every path segment written as `{domain}` is
      the domain **name** (`example.com`), not a numeric id. Percent-encode it if
      it contains characters that are unsafe in a path segment.
    * **Errors** — every error body carries `error`, a human-readable message.
      Some of them also carry `code`, a stable machine-readable reason, when the
      distinction matters more than the sentence: `account_suspended`,
      `domain_disabled`, `managed_by_reseller` and `invite_email_mismatch` (which
      adds `invited_email` as well). Branch on `code` where it exists and never on
      the wording of `error`. See the `Error` schema for what each one means. One
      endpoint breaks even that shape: the removed `GET /subscriptions/current`
      puts a machine string in `error` and the prose in `message`.
    * **Timestamps** — RFC 3339 / ISO 8601 strings in UTC unless stated otherwise.
    * **Byte counts** — always bytes; **traffic and quota** values are documented
      per field.
    * **Access control** — a key inherits the permissions of the user who owns it.
      For a shared domain that is the role granted to that user (`viewer`,
      `editor`, `admin`); for your own domains it is `owner`. Endpoints document
      the permission they require, and return `403` when the role lacks it and
      `404` when the domain is not visible to you at all.

    ## Plan features

    Several endpoints are gated on the domain's active plan (analytics, logs,
    WAF, custom certificates, …). When the plan does not include the feature the
    response is `403` with an `error` explaining which feature is missing.

  contact:
    name: NSIN Support
    url: https://nsin.cloud
  license:
    name: Proprietary

servers:
  # api.nsin.cloud, NOT nsin.cloud/api. There is no reverse proxy mapping the
  # /api prefix onto this service: nsin.cloud is the Next.js marketing site,
  # it is built with trailingSlash: true, and so it answers every /api/... call
  # with a 308 to the same path plus a slash and then a prerendered HTML 404.
  #
  # A partner who copies this value out of the reference — or generates a
  # client from this document — POSTs money-moving requests into a static
  # site and gets HTML back where he expects JSON. That is what it did for
  # two days. api.nsin.cloud is the host Coolify actually routes to this
  # container (COOLIFY_FQDN), and TestPublishedServerIsTheAPIHost keeps the
  # two from drifting again.
  - url: https://api.nsin.cloud
    description: Production

security:
  - bearerAuth: []
  - apiKeyAuth: []

tags:
  - name: Domains
    description: Add, configure, verify and remove domains.
  - name: DNS Records
    description: DNS record CRUD, bulk operations, zone scan and import.
  - name: SSL
    description: Certificate status, manual issuance and custom certificate upload.
  - name: Rules
    description: |
      Edge rules — cache, WAF, redirect, rewrite, rate limit, captcha, bot
      routing, origin selection, fingerprinting and error pages.

      Every rule type below answers the same way on access. Reading needs
      `domain.view`, which every role has. Creating, updating, deleting,
      toggling and reordering need `rules.edit`, which a `viewer` does not
      have — those calls answer `403`, not `404`. Any of those writes against a
      disabled domain answers `409` with `code: domain_disabled`, while the
      rules stay readable. Only a domain that does not exist, or that you hold
      no role on, is a `404`.
  - name: Cache
    description: Cache statistics, key browsing and purging.
  - name: Analytics
    description: Traffic analytics, request logs and ad-hoc queries over your own traffic.
  - name: Uptime
    description: Origin outage incidents and detection settings.
  - name: Recommendations
    description: Per-domain advisory checklist.
  - name: Sharing
    description: Domain members and invitations.
  - name: Billing
    description: Read-only access to subscriptions, features, traffic usage, invoices and wallet.
  - name: Support
    description: Support tickets.
  - name: Account
    description: Account-wide reads.

paths:

  # ---------------------------------------------------------------------------
  # Domains
  # ---------------------------------------------------------------------------

  /domains/:
    get:
      tags: [Domains]
      operationId: listDomains
      summary: List domains
      description: |
        Every domain you can access — owned and shared with you — each with a
        short SSL summary, its active subscription and your role on it.
      responses:
        "200":
          description: Domain list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/DomainWithSsl" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Domains]
      operationId: createDomain
      summary: Add a domain
      description: |
        Registers a domain on your account.

        * `dns_mode: managed` (default) — NSIN hosts the zone. The domain starts
          in `pending` until its nameservers point at the NSIN set returned by
          `GET /domains/ns-sets`, then flips to `active` automatically.
        * `dns_mode: external` — you keep DNS elsewhere. The domain starts in
          `unverified` and you prove ownership with the TXT record from the
          `verification` block, then call `POST /domains/{domain}/verify`.

        Existing records are scanned and imported in the background for managed
        domains.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DomainCreate" }
      responses:
        "200":
          description: Domain created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "400":
          description: Invalid or unsupported domain name.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "409":
          description: The domain already exists on this or another account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/ns-sets:
    get:
      tags: [Domains]
      operationId: listNameserverSets
      summary: List accepted nameserver sets
      description: |
        The nameserver sets a managed domain's delegation may match. The
        delegation must match **exactly one set in full** — nameservers from
        different sets cannot be mixed. The first set is the one shown in the
        panel and is the recommended choice.
      responses:
        "200":
          description: Accepted nameserver sets.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sets:
                    type: array
                    description: Each entry is one complete, acceptable nameserver set.
                    items:
                      type: array
                      items: { type: string }
                    examples:
                      - [["th.ns.nsin.ir", "ny.ns.nsin.ir", "eu.ns.nsin.ir"]]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Domains]
      operationId: getDomain
      summary: Get a domain
      description: |
        Full domain state, including nameserver/verification progress, your role
        and permissions on it, and every edge setting.
      responses:
        "200":
          description: Domain detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Domains]
      operationId: updateDomain
      summary: Update domain settings
      description: |
        Partial update — omitted fields are left unchanged. Requires the
        `domain.settings` permission.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DomainUpdate" }
      responses:
        "200":
          description: Updated domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "400":
          description: |
            Invalid value — e.g. `dns_mode` not `managed`/`external`,
            `cache_l2_ttl_days` outside 1–7, or `cache_cap_mb` not one of the
            allowed tiers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            Read-only key, insufficient role, or the requested `cache_cap_mb`
            exceeds what the domain's plan allows.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Domains]
      operationId: deleteDomain
      summary: Delete a domain
      description: |
        Removes the domain, its records, rules and DNS zone. Owner only
        (`domain.delete`). This cannot be undone.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/developer-mode:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: enableDeveloperMode
      summary: Enable developer mode
      description: |
        Bypasses all cache reads and writes for this domain at the edge, so you
        always see the origin's current response. Auto-expires — the response
        carries the expiry — so a forgotten toggle can never permanently disable
        caching. Requires `domain.settings`.
      responses:
        "200":
          description: Developer mode enabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeveloperMode" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Domains]
      operationId: disableDeveloperMode
      summary: Disable developer mode
      description: Turns developer mode off immediately. Requires `domain.settings`.
      responses:
        "200":
          description: Developer mode disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeveloperMode" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/enable:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: enableDomain
      summary: Re-enable a disabled domain
      description: |
        Brings a `disabled` domain back into service. A managed domain returns to
        `pending` and is re-checked against the NSIN nameservers. Requires
        `domain.settings`.
      responses:
        "200":
          description: Domain re-enabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "400":
          description: The domain is not in the `disabled` state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/check-ns:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: checkNameservers
      summary: Check nameserver delegation now
      description: |
        Runs an immediate delegation check for a `pending` or `moved` **managed**
        domain instead of waiting for the background checker. On success the
        domain is activated right away.

        Rate-limited to once per hour per domain, independently of the API key
        rate limit. Requires `domain.settings`.
      responses:
        "200":
          description: Check result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NsCheckResult" }
        "400":
          description: |
            Not a managed domain, or the domain is not awaiting nameserver
            changes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429":
          description: |
            Either the once-per-hour manual check limit or the API key rate
            limit was hit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /domains/{domain}/verify:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: verifyDomain
      summary: Verify an external-DNS domain
      description: |
        Checks for the TXT record described by the domain's `verification` block
        and activates the domain when it is found. Only valid for
        `dns_mode: external`. Requires `domain.settings`.
      responses:
        "200":
          description: Verified — the domain is now active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyResult" }
        "400":
          description: Not an external-DNS domain, or not awaiting verification.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The verification token has expired; call `verify/retry` for a fresh one.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: |
            The TXT record was not found or did not match. `verified` is `false`
            and `error` explains what was seen.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  verified: { type: boolean, const: false }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/verify/retry:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Domains]
      operationId: retryDomainVerification
      summary: Issue a fresh verification token
      description: |
        Resets a failed external-DNS verification and mints a new TXT token. Use
        the `verification` block of the response as the new record to publish.
        Requires `domain.settings`.
      responses:
        "200":
          description: Verification reset with a new token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainDetail" }
        "400":
          description: Not an external-DNS domain, or not in the failed-verification state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # SSL
  # ---------------------------------------------------------------------------

  /domains/{domain}/ssl/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [SSL]
      operationId: getSslInfo
      summary: Get certificate status
      description: |
        The domain's current certificate — issuer, validity, SANs, key size — plus
        whether a manual re-issue is currently allowed, and `coverage`: proxied
        hostnames that are **not** on the certificate yet, with their retry state.
      responses:
        "200":
          description: Certificate status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SslInfo" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [SSL]
      operationId: uploadCustomCertificate
      summary: Upload a custom certificate
      description: |
        Installs your own certificate and private key for the domain. Include the
        full chain (leaf **and** intermediates) in `certificate` — a leaf-only
        upload makes clients fail chain verification.

        `hostnames` selects which of the certificate's SANs this upload should
        cover; use the `eligible` list from `POST /domains/{domain}/ssl/parse` to
        pick them. Requires `ssl.manage` and a plan that includes custom
        certificates.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomCertificateUpload" }
      responses:
        "200":
          description: Certificate installed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomCertificateResult" }
        "400":
          description: |
            Missing fields, unparseable PEM, key/certificate mismatch, an expired
            certificate, or a hostname that the certificate does not cover.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Read-only key, insufficient role, or the plan does not include custom certificates.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/ssl/parse:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [SSL]
      operationId: parseCustomCertificate
      summary: Inspect a certificate before uploading
      description: |
        Parses a certificate PEM and reports its subject, issuer, validity and
        SANs — without installing anything. `eligible` lists the SANs that belong
        to this domain and may therefore be passed as `hostnames` to the upload
        call; `default_selection` is the subset the panel pre-selects.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [certificate]
              properties:
                certificate:
                  type: string
                  description: PEM-encoded certificate.
      responses:
        "200":
          description: Parsed certificate.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ParsedCertificate" }
        "400":
          description: Missing or unparseable certificate PEM.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/ssl/issue:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [SSL]
      operationId: issueCertificate
      summary: Request certificate issuance
      description: |
        Starts an ACME order for the domain. Allowed only when SSL is currently
        `missing` or `failed` — certificates are otherwise issued and renewed
        automatically. Not available for `dns_mode: external` domains.

        Issuance is asynchronous: this returns immediately with
        `status: "pending"`; poll `GET /domains/{domain}/ssl/` for the outcome.
        Manual attempts are rate-limited per domain — `GET /ssl/` reports
        `can_manual_issue` and `next_manual_issue_at`. Requires `ssl.manage`.
      responses:
        "200":
          description: Issuance started.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, examples: ["SSL issuance started"] }
                  status: { type: string, const: pending }
                  last_issue_attempt_at: { type: string, format: date-time }
        "400":
          description: |
            The domain uses external DNS, or SSL is not in a state where a manual
            issue is allowed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: An issuance is already in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: |
            The per-domain manual issuance cooldown has not elapsed, or the API
            key rate limit was hit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: The certificate issuer is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ---------------------------------------------------------------------------
  # DNS Records
  # ---------------------------------------------------------------------------

  /domains/{domain}/records/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [DNS Records]
      operationId: listRecords
      summary: List DNS records
      description: |
        All records of the domain, newest first.

        Proxied records may carry `origin_rules` — origin route or origin pool
        rules that override where that record's traffic actually goes, so the
        effective origin is **not** the record's `destination`. Routes are listed
        before pools, mirroring edge precedence.
      responses:
        "200":
          description: Record list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RecordWithOriginRules" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [DNS Records]
      operationId: createRecord
      summary: Create a DNS record
      description: |
        Creates one record and publishes it to the DNS zone.

        Setting `proxied: true` routes the hostname through the NSIN edge: the
        published DNS answer becomes the NSIN proxy IP and `destination` becomes
        the origin the edge connects to. Only `A`, `AAAA`, `CNAME` and `ANAME`
        can be proxied. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecordCreate" }
      responses:
        "200":
          description: Record created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Record" }
        "400":
          description: Invalid record — bad type, malformed destination, or a value the zone rejects.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The domain is disabled, or a conflicting record already exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/{recordId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RecordId"
    put:
      tags: [DNS Records]
      operationId: updateRecord
      summary: Update a DNS record
      description: |
        Partial update — omitted fields keep their current value. Requires
        `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecordUpdate" }
      responses:
        "200":
          description: Updated record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Record" }
        "400":
          description: Invalid value, or the record is not editable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or record not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [DNS Records]
      operationId: deleteRecord
      summary: Delete a DNS record
      description: Removes the record from the zone. Requires `records.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or record not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/batch-update:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: batchUpdateRecords
      summary: Update many records at once
      description: |
        Applies an update to many records in one request. Each item accepts
        exactly the same optional fields as a single `PUT`.

        **Best-effort:** every record is processed independently, so one bad
        record does not abort the rest. The response always returns `200` with a
        per-record `results` array — check it rather than the status code.
        Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [updates]
              properties:
                updates:
                  type: array
                  items:
                    allOf:
                      - type: object
                        required: [id]
                        properties:
                          id: { type: integer, description: Id of the record to update. }
                      - $ref: "#/components/schemas/RecordUpdate"
      responses:
        "200":
          description: Per-record outcome.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/batch-delete:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: batchDeleteRecords
      summary: Delete many records at once
      description: |
        Deletes many records in one request. Best-effort per record — see
        `batch-update`. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items: { type: integer }
      responses:
        "200":
          description: Per-record outcome.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/scan:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [DNS Records]
      operationId: scanRecords
      summary: Scan the domain's existing DNS from public resolvers
      description: |
        Queries public resolvers for records that already exist for this domain
        and returns them as an import preview — nothing is written. Each entry is
        marked `new`, `overwrite` (an NSIN record with the same name and type
        already exists) or `unsupported`.

        Use this to review before calling `scan-import`. Requires `records.edit`.
      responses:
        "200":
          description: Scan preview.
          content:
            application/json:
              schema:
                type: object
                properties:
                  records:
                    type: array
                    items: { $ref: "#/components/schemas/ImportPreviewRecord" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/scan-import:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: scanImportRecords
      summary: Scan and import in one step
      description: |
        Scans the domain's existing DNS from public resolvers and imports
        everything it finds, without a review step. Convenient right after adding
        a domain. Requires `records.edit`.
      responses:
        "200":
          description: Import outcome, plus the domain's full record list afterwards.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The domain is disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/import/parse:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: parseZoneFile
      summary: Parse a zone file into an import preview
      description: |
        Accepts a BIND-style zone file and returns what would be imported, with
        each entry marked `new`, `overwrite` or `unsupported`. Nothing is
        written — pass the entries you want to `POST .../records/import`.
        Requires `records.edit`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: The zone file to parse.
          text/plain:
            schema:
              type: string
              description: Raw zone file contents.
      responses:
        "200":
          description: Import preview.
          content:
            application/json:
              schema:
                type: object
                properties:
                  records:
                    type: array
                    items: { $ref: "#/components/schemas/ImportPreviewRecord" }
        "400":
          description: The zone file could not be parsed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/import:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: importRecords
      summary: Import records
      description: |
        Creates the supplied records, overwriting any existing record with the
        same name and type. Best-effort per record — the response counts what
        succeeded and lists what failed. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [records]
              properties:
                records:
                  type: array
                  items: { $ref: "#/components/schemas/ImportRecordItem" }
      responses:
        "200":
          description: Import outcome.
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  overwritten: { type: integer }
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        type: { type: string }
                        error: { type: string }
        "400":
          description: Malformed request body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }


  # ---------------------------------------------------------------------------
  # Rules
  #
  # Every rule type exposes the same seven operations. Note that the path
  # segment is hyphenated for some types (rate-limit, bot-route, error-page)
  # and underscored for others (origin_pool, origin_route).
  # ---------------------------------------------------------------------------

  /domains/{domain}/rules/cache/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listCacheRules
      summary: List cache rules
      description: |
        Decides what the edge caches, for how long, and which safety bypasses apply. A domain may hold several cache rules with different tradeoffs; each is self-contained.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Cache rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CacheRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createCacheRule
      summary: Create a cache rule
      description: |
        Decides what the edge caches, for how long, and which safety bypasses apply. A domain may hold several cache rules with different tradeoffs; each is self-contained.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CacheRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/cache/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderCacheRules
      summary: Reorder cache rules
      description: |
        Sets the `priority` of several cache rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/cache/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getCacheRule
      summary: Get a cache rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateCacheRule
      summary: Update a cache rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CacheRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteCacheRule
      summary: Delete a cache rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/cache/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleCacheRule
      summary: Enable or disable a cache rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/drop/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listDropRules
      summary: List drop rules
      description: |
        Blocks matching requests at the edge, optionally restricted by visitor country.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Drop rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/DropRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createDropRule
      summary: Create a drop rule
      description: |
        Blocks matching requests at the edge, optionally restricted by visitor country.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DropRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DropRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/drop/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderDropRules
      summary: Reorder drop rules
      description: |
        Sets the `priority` of several drop rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/drop/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getDropRule
      summary: Get a drop rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DropRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateDropRule
      summary: Update a drop rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DropRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DropRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteDropRule
      summary: Delete a drop rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/drop/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleDropRule
      summary: Enable or disable a drop rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DropRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/redirect/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listRedirectRules
      summary: List redirect rules
      description: |
        Returns an HTTP redirect for matching requests instead of proxying them to the origin.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Redirect rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RedirectRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createRedirectRule
      summary: Create a redirect rule
      description: |
        Returns an HTTP redirect for matching requests instead of proxying them to the origin.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RedirectRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/redirect/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderRedirectRules
      summary: Reorder redirect rules
      description: |
        Sets the `priority` of several redirect rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/redirect/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getRedirectRule
      summary: Get a redirect rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateRedirectRule
      summary: Update a redirect rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RedirectRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteRedirectRule
      summary: Delete a redirect rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/redirect/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleRedirectRule
      summary: Enable or disable a redirect rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RedirectRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rewrite/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listRewriteRules
      summary: List rewrite rules
      description: |
        Rewrites the path and/or query string before the request is sent to the origin. The visitor's URL is unchanged.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Rewrite rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RewriteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createRewriteRule
      summary: Create a rewrite rule
      description: |
        Rewrites the path and/or query string before the request is sent to the origin. The visitor's URL is unchanged.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RewriteRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewriteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rewrite/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderRewriteRules
      summary: Reorder rewrite rules
      description: |
        Sets the `priority` of several rewrite rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rewrite/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getRewriteRule
      summary: Get a rewrite rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewriteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateRewriteRule
      summary: Update a rewrite rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RewriteRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewriteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteRewriteRule
      summary: Delete a rewrite rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rewrite/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleRewriteRule
      summary: Enable or disable a rewrite rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RewriteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/waf/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listWafRules
      summary: List WAF rules
      description: |
        Runs the OWASP Core Rule Set against matching requests at the chosen paranoia level and blocks once the anomaly score passes the threshold.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: WAF rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WafRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createWafRule
      summary: Create a WAF rule
      description: |
        Runs the OWASP Core Rule Set against matching requests at the chosen paranoia level and blocks once the anomaly score passes the threshold.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WafRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/waf/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderWafRules
      summary: Reorder WAF rules
      description: |
        Sets the `priority` of several WAF rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/waf/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getWafRule
      summary: Get a WAF rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateWafRule
      summary: Update a WAF rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WafRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteWafRule
      summary: Delete a WAF rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/waf/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleWafRule
      summary: Enable or disable a WAF rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/captcha/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listCaptchaRules
      summary: List captcha rules
      description: |
        Challenges visitors on matching paths before letting them through. A solved challenge is remembered for `ttl_sec`.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Captcha rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CaptchaRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createCaptchaRule
      summary: Create a captcha rule
      description: |
        Challenges visitors on matching paths before letting them through. A solved challenge is remembered for `ttl_sec`.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CaptchaRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptchaRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/captcha/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderCaptchaRules
      summary: Reorder captcha rules
      description: |
        Sets the `priority` of several captcha rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/captcha/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getCaptchaRule
      summary: Get a captcha rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptchaRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateCaptchaRule
      summary: Update a captcha rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CaptchaRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptchaRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteCaptchaRule
      summary: Delete a captcha rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/captcha/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleCaptchaRule
      summary: Enable or disable a captcha rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptchaRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rate-limit/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listRateLimitRules
      summary: List rate limit rules
      description: |
        Counts requests per key over a sliding window and drops or challenges the ones above the limit.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Rate limit rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/RateLimitRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createRateLimitRule
      summary: Create a rate limit rule
      description: |
        Counts requests per key over a sliding window and drops or challenges the ones above the limit.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RateLimitRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLimitRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rate-limit/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderRateLimitRules
      summary: Reorder rate limit rules
      description: |
        Sets the `priority` of several rate limit rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rate-limit/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getRateLimitRule
      summary: Get a rate limit rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLimitRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateRateLimitRule
      summary: Update a rate limit rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RateLimitRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLimitRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteRateLimitRule
      summary: Delete a rate limit rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/rate-limit/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleRateLimitRule
      summary: Enable or disable a rate limit rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLimitRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/bot-route/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listBotRouteRules
      summary: List bot route rules
      description: |
        Acts on classified bot traffic — block it, serve alternative content, send it to a different origin, or just tag it in telemetry.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Bot route rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/BotRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createBotRouteRule
      summary: Create a bot route rule
      description: |
        Acts on classified bot traffic — block it, serve alternative content, send it to a different origin, or just tag it in telemetry.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BotRouteRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BotRouteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/bot-route/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderBotRouteRules
      summary: Reorder bot route rules
      description: |
        Sets the `priority` of several bot route rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/bot-route/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getBotRouteRule
      summary: Get a bot route rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BotRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateBotRouteRule
      summary: Update a bot route rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BotRouteRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BotRouteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteBotRouteRule
      summary: Delete a bot route rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/bot-route/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleBotRouteRule
      summary: Enable or disable a bot route rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BotRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_pool/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listOriginPoolRules
      summary: List origin pool rules
      description: |
        Load-balances matching traffic across several origins with optional health checking. Overrides the DNS record's own destination for every path it matches.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Origin pool rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OriginPoolRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createOriginPoolRule
      summary: Create a origin pool rule
      description: |
        Load-balances matching traffic across several origins with optional health checking. Overrides the DNS record's own destination for every path it matches.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OriginPoolRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginPoolRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_pool/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderOriginPoolRules
      summary: Reorder origin pool rules
      description: |
        Sets the `priority` of several origin pool rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_pool/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getOriginPoolRule
      summary: Get a origin pool rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginPoolRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateOriginPoolRule
      summary: Update a origin pool rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OriginPoolRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginPoolRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteOriginPoolRule
      summary: Delete a origin pool rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_pool/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleOriginPoolRule
      summary: Enable or disable a origin pool rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginPoolRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_route/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listOriginRouteRules
      summary: List origin route rules
      description: |
        Sends matching paths to a different origin than the DNS record's destination. Takes precedence over origin pools.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Origin route rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OriginRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createOriginRouteRule
      summary: Create a origin route rule
      description: |
        Sends matching paths to a different origin than the DNS record's destination. Takes precedence over origin pools.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OriginRouteRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginRouteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_route/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderOriginRouteRules
      summary: Reorder origin route rules
      description: |
        Sets the `priority` of several origin route rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_route/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getOriginRouteRule
      summary: Get a origin route rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateOriginRouteRule
      summary: Update a origin route rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OriginRouteRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginRouteRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteOriginRouteRule
      summary: Delete a origin route rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/origin_route/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleOriginRouteRule
      summary: Enable or disable a origin route rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginRouteRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/fingerprint/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listFingerprintRules
      summary: List fingerprint rules
      description: |
        Matches requests on their TLS/HTTP fingerprint (JA4, JA4H) and drops, challenges or tags them.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Fingerprint rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/FingerprintRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createFingerprintRule
      summary: Create a fingerprint rule
      description: |
        Matches requests on their TLS/HTTP fingerprint (JA4, JA4H) and drops, challenges or tags them.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FingerprintRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FingerprintRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/fingerprint/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderFingerprintRules
      summary: Reorder fingerprint rules
      description: |
        Sets the `priority` of several fingerprint rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/fingerprint/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getFingerprintRule
      summary: Get a fingerprint rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FingerprintRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateFingerprintRule
      summary: Update a fingerprint rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FingerprintRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FingerprintRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteFingerprintRule
      summary: Delete a fingerprint rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/fingerprint/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleFingerprintRule
      summary: Enable or disable a fingerprint rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FingerprintRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/error-page/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listErrorPageRules
      summary: List error page rules
      description: |
        Controls what visitors see for selected status codes — the NSIN branded page, your own HTML, or the origin's own response passed through untouched.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Error page rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ErrorPageRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createErrorPageRule
      summary: Create a error page rule
      description: |
        Controls what visitors see for selected status codes — the NSIN branded page, your own HTML, or the origin's own response passed through untouched.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ErrorPageRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorPageRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/RulePlanLimited" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/error-page/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderErrorPageRules
      summary: Reorder error page rules
      description: |
        Sets the `priority` of several error page rules at once. Lower priority
        values are evaluated first. The body is a bare array. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleReorderRequest" }
      responses:
        "200":
          description: Number of rules whose priority changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleReorderResult" }
        "400":
          description: Malformed body, or a negative priority.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/error-page/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getErrorPageRule
      summary: Get a error page rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorPageRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateErrorPageRule
      summary: Update a error page rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ErrorPageRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorPageRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteErrorPageRule
      summary: Delete a error page rule
      description: Requires `rules.edit`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/error-page/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleErrorPageRule
      summary: Enable or disable a error page rule
      description: |
        Flips the rule's `enabled` flag. Takes no request body — the new state
        is always the opposite of the current one, and is returned. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorPageRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }


  # ---------------------------------------------------------------------------
  # Analytics
  #
  # Analytics is served from a request-log store, so figures for the last minute
  # or two may still be settling.
  #
  # The per-domain sections all take `?domain=` (the domain NAME) and share the
  # `period`, `hostname` and `path` filters. Most require a plan that includes
  # the `monitoring` feature; the raw-log endpoints require `logs`.
  # ---------------------------------------------------------------------------

  /analytics/overview:
    get:
      tags: [Analytics]
      operationId: analyticsOverview
      summary: Per-domain totals across your account
      description: |
        One row per domain you can access, with request, bandwidth and visitor
        totals for the period. Account-wide — takes no `domain` parameter.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: One entry per domain.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/OverviewItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/domains-overview:
    get:
      tags: [Analytics]
      operationId: analyticsDomainsOverview
      summary: Per-domain totals with sparkline
      description: |
        Like `/analytics/overview`, plus an error rate, a small
        requests-over-time series for sparklines, and the most recent log
        timestamp seen for each domain.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: One entry per domain.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/DomainsOverviewItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/global-summary:
    get:
      tags: [Analytics]
      operationId: analyticsGlobalSummary
      summary: Account-wide summary
      description: Headline figures aggregated across every domain you can access.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: Account-wide totals.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GlobalSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/bandwidth-overview:
    get:
      tags: [Analytics]
      operationId: analyticsBandwidthOverview
      summary: Origin-direction bandwidth across your domains
      description: |
        Bytes sent to and received from origins, as a time series plus per-domain
        totals.

        `ratio` is `min(up,down) / max(up,down)`. A value near `1.0` means the
        domain pushes about as much to the origin as it pulls back, which is
        unusual for web traffic (downloads normally dominate) and sets
        `flagged`.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: Bandwidth series and per-domain totals.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginBandwidthResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/tunnel-suspects:
    get:
      tags: [Analytics]
      operationId: analyticsTunnelSuspects
      summary: Clients whose traffic resembles a proxy tunnel
      description: |
        Clients whose WebSocket/gRPC traffic looks like a VPN or proxy tunnel run
        behind the CDN: sustained volume over a single fixed path, with opaque
        payloads and no sign of ordinary browsing (no real assets fetched, no
        referer).

        This is a heuristic for investigation, not proof of abuse. `balance` is
        informational — tunnels used for browsing are download-heavy, so
        symmetry is **not** a criterion.
      parameters:
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: Suspected tunnel clients.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TunnelSuspectsResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/nodes:
    get:
      tags: [Analytics]
      operationId: analyticsListNodes
      summary: List edge nodes
      description: |
        Active edge nodes (points of presence). Use `name` as the `node` filter
        on `/analytics/traffic-by-node` and `/analytics/origins`.
      responses:
        "200":
          description: Edge nodes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, description: Node identifier used in filters. }
                        label: { type: string, description: Human-readable name. }
                        country: { type: string, description: ISO country code. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /analytics/node-countries:
    get:
      tags: [Analytics]
      operationId: analyticsNodeCountries
      summary: List edge node countries
      description: The distinct countries edge nodes are located in.
      responses:
        "200":
          description: Country codes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /analytics/summary:
    get:
      tags: [Analytics]
      operationId: analyticsSummary
      summary: Traffic summary for one domain
      description: |
        Headline figures for the domain over the period: requests, bandwidth,
        unique visitors, error rate and latency percentiles.

        Unique visitors are counted as distinct (client IP, JA4 TLS
        fingerprint) pairs, which separates people sharing one NAT address by
        device. On plain HTTP there is no JA4, so it degrades to counting IPs.
        Latency figures exclude WebSocket requests, whose duration spans the
        whole connection.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Summary figures.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyticsSummary" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/requests-compare:
    get:
      tags: [Analytics]
      operationId: analyticsRequestsCompare
      summary: Request counts across days, for overlaying
      description: |
        Request counts bucketed by hour or by day over a window of whole days,
        so the dashboard can draw one line per day and compare them.

        Scope, in order:
          * `domain_id` given - that domain alone, and 404 if the caller may not see it.
          * `scope=all` - every domain. Admins only; anyone else falls through.
          * otherwise - every ACTIVE domain the caller owns or is a member of.

        The scoping parameter is `domain_id`, in snake_case. It is the odd one
        out on this surface, and getting it wrong is silent: a camelCase
        `domainId` is never read, so the handler sees no id, falls through to
        the third branch, and answers for every domain you can see instead of
        the one you asked for. Nothing in the response says the scope was
        widened.

        `domain_id` selects that domain whatever its status; the fall-through
        counts ACTIVE domains only. Naming a `pending` or `disabled` domain by
        id therefore returns traffic the account-wide query leaves out.

        Buckets are anchored to local midnight, so today's partial day is still a
        whole-day bucket and lines stay aligned.
      parameters:
        - name: granularity
          in: query
          schema: { type: string, enum: [hour, day], default: hour }
          description: |
            `hour` overlays one line per day; `day` gives a single daily series.
            Only `day` is recognised as itself: every other value, misspelling
            included, is silently treated as `hour` rather than rejected.
        - name: days
          in: query
          schema: { type: integer, minimum: 1, maximum: 35 }
          description: |
            Window length in whole days. Both the default and the cap depend on
            `granularity`: `hour` defaults to 3 and is capped at 14, `day`
            defaults to 14 and is capped at 35 - the ClickHouse rows expire at
            35 days, so nothing older exists to chart. The schema's `maximum` is
            the higher of the two caps because one schema cannot express the
            split; over the cap for the granularity you asked for, the value is
            clamped, not rejected, and 0, a negative number or a non-numeric
            value takes the default. Either way the window can come back shorter
            than you asked for with no error.
        - name: scope
          in: query
          schema: { type: string, enum: [domains, all], default: domains }
          description: |
            `all` is honoured for admins only; for anyone else it is ignored and
            the query stays scoped to their own domains. It is also ignored
            whenever `domain_id` is set - that branch wins outright.
        - name: domain_id
          in: query
          description: |
            Numeric domain id. snake_case, not `domainId` - see the scoping note
            above. 404 if the caller may not see the domain.
          schema: { type: integer }
      responses:
        "200":
          description: |
            Time series. This operation formats `timestamp` as
            `2026-08-16 13:00:00` - a space separator and no zone suffix - not
            the RFC 3339 `2026-08-16T13:00:00Z` the shared schema's
            `format: date-time` implies and the other series endpoints return.
            Parse it as a local-time stamp, not as RFC 3339.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RequestsDataPoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/requests:
    get:
      tags: [Analytics]
      operationId: analyticsRequests
      summary: Requests over time
      description: |
        Request counts bucketed by hour (periods up to 24h) or by day (`7d`,
        `30d`).
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RequestsDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/visitors:
    get:
      tags: [Analytics]
      operationId: analyticsVisitors
      summary: Unique visitors over time
      description: |
        Distinct visitors per bucket, counted as (client IP, JA4) pairs. Note
        that visitors do not sum across buckets — the same person appears in
        every bucket they were active in.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RequestsDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/bandwidth:
    get:
      tags: [Analytics]
      operationId: analyticsBandwidth
      summary: Bandwidth over time
      description: Bytes in and out per bucket, from the visitor's perspective.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BandwidthDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/top-uris:
    get:
      tags: [Analytics]
      operationId: analyticsTopUris
      summary: Most requested URIs
      description: |
        The ten busiest page URLs for the domain, by request count. The limit is hardcoded — there is no `limit` and no paging — and `domain` names exactly one domain: unlike `/analytics/requests-compare`, the per-domain sections have no account-wide or admin scope.

        Rows are grouped by the whole `uri`, query string included, so `/search?q=a` and `/search?q=b` compete for separate slots. URIs whose path ends in a `.<ext>` are excluded in SQL so that assets (`.css`, `.js`, `.png`, fonts) cannot crowd out pages — which also excludes `.html` and `.php`, so a site that serves its pages with an extension sees very little here; `/analytics/top-requests?metric=uris` is the unfiltered ranking (100 rows, query string stripped), but it needs a plan with the `logs` feature where this one needs only `monitoring`. The `403` is always that plan gate and never your role — every role that can see a domain, down to `viewer`, carries `analytics.view` — and a domain with no active plan at all answers `402` rather than `403`.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Top URIs by request count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TopUri" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/top-requests:
    get:
      tags: [Analytics]
      operationId: analyticsTopRequests
      summary: Top-N breakdown by a chosen metric
      description: |
        A ranked breakdown of the domain's traffic. `metric` selects what is
        ranked, and which fields of each row are populated — rows omit the
        fields that do not apply.

        Requires a plan including the `logs` feature.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - name: metric
          in: query
          required: true
          description: |
            * `slow_requests` — slowest paths, with average and maximum duration. Excludes WebSockets.
            * `uris` — most requested paths.
            * `errors_5xx` — paths returning server errors.
            * `hosts` — busiest subdomains.
            * `countries` — busiest visitor countries.
            * `user_agents` — busiest user agents.
            * `networks` — busiest visitor networks, keyed `AS<number>` with the operator in `label`.
          schema:
            type: string
            enum: [slow_requests, uris, errors_5xx, hosts, countries, user_agents, networks]
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Ranked rows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TopRequestRow" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/countries:
    get:
      tags: [Analytics]
      operationId: analyticsCountries
      summary: Traffic by visitor country
      description: |
        Per-country totals for the domain — requests, bytes served to visitors, and unique visitors — ranked by requests and truncated to the top 20 countries.

        The country is a GeoLite2 lookup of the client IP performed once at ingest, so a visitor falls in exactly one country and these `unique_visitors` do sum to the `unique_visitors` of `/analytics/summary`, within `uniq()`'s HLL error and only when the domain saw traffic from 20 countries or fewer. IPs the database cannot place — and every request logged while no GeoIP database was loaded at all — are grouped under the code `ZZ`, which is a bucket rather than a country.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-country totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CountryStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/asns:
    get:
      tags: [Analytics]
      operationId: analyticsAsns
      summary: Traffic by visitor network (ASN)
      description: |
        The ten busiest visitor networks for the domain, by request count, each with its autonomous system number, an operator name and bytes served.

        Requests whose ASN could not be resolved are dropped from the query (`asn != 0`), so these counts do not add up to the domain's total requests, and if `GeoLite2-ASN.mmdb` was never shipped to the backend the endpoint returns an empty list while traffic is plainly flowing. `asn_org` is `any()` of the operator strings recorded for that ASN in the window, so a network that changed its registered name mid-period shows one of them arbitrarily.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-network totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AsnStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/protocols:
    get:
      tags: [Analytics]
      operationId: analyticsProtocols
      summary: Traffic by HTTP protocol version
      description: |
        Request counts by the version the client spoke to the edge, bucketed into `h1`, `h2`, `h3` and `other` and ordered by count — at most four rows, and only for buckets that saw traffic.

        `other` is a catch-all for anything not logged as `HTTP/1.x`, `HTTP/2.x` or `HTTP/3.x`, including requests logged with no protocol string at all, so it is not a fifth protocol. The version the edge spoke to your origin is a separate field and appears only on raw log rows (`origin_protocol` on `/analytics/logs`, which needs the `logs` feature).
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-protocol request counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ProtocolStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/status-codes:
    get:
      tags: [Analytics]
      operationId: analyticsStatusCodes
      summary: Traffic by HTTP status code
      description: |
        One row per distinct status the domain returned, ordered by count descending rather than by code, and deliberately uncapped — a domain being scanned can come back with dozens of rows, so do not assume a short list.

        Failed requests are counted here too, under whatever status the edge showed the visitor rather than anything the origin returned; `/analytics/unreachable-reasons` breaks those same requests down by cause and fault. Note that the `503` fires only when the ClickHouse connection was never opened at startup: an analytics store that is merely down, or a query that outruns its timeout (10s, rising to 20s for `7d` and 45s for `30d`), answers `500` instead, so retry logic should treat the two alike.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-status-code counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/StatusCodeStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/unreachable-reasons:
    get:
      tags: [Analytics]
      operationId: analyticsUnreachableReasons
      summary: Why requests could not be served
      description: |
        A breakdown of failed requests by cause, with plain-language
        explanations and who is responsible (`client`, `origin`, `network` or
        `config`) — so you can tell a visitor hanging up from your server
        crashing without reading raw proxy errors.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Failure reasons.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/UnreachableReason" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/cache:
    get:
      tags: [Analytics]
      operationId: analyticsCache
      summary: Cache hit, miss and bypass counts
      description: |
        `hit_rate` is `hits / (hits + misses)` — bypasses are excluded from the
        denominator, since a bypassed request was never a caching candidate.
        `bypass_reasons` breaks down why requests bypassed the cache.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Cache counters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheAnalytics" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/traffic-by-cache:
    get:
      tags: [Analytics]
      operationId: analyticsTrafficByCache
      summary: Egress bytes by cache status over time
      description: |
        Bytes served to visitors per time bucket, split three ways by cache status: `hit`, `miss` and `bypass`.

        Requests logged with no cache status never entered the caching pipeline and count towards none of the three, so the columns do not add up to the `bytes_out` of `/analytics/bandwidth`; `/analytics/traffic-by-reqstatus` is the orthogonal split of the same bytes by serving path. Buckets are hourly up to `24h` and daily for `7d` and `30d`, but the window starts exactly one period ago while buckets are anchored to the hour or day, so the oldest bucket is partial — and buckets with no traffic are omitted rather than zero-filled, so a chart has to fill its own gaps. It also honours a `node` filter (a `name` from `GET /analytics/nodes`) that is not listed above. As with every section endpoint, the response is cached in-process per domain, period and filter — one minute, three at `7d`, ten at `30d` — so polling faster than that returns the same numbers.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TrafficByCacheDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/traffic-by-reqstatus:
    get:
      tags: [Analytics]
      operationId: analyticsTrafficByReqStatus
      summary: Egress bytes by serving path over time
      description: |
        Serving path is orthogonal to cache status: `cache` went through the
        caching pipeline, `proxied` reached the origin through the edge proxy,
        and `direct` reached the origin without it.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TrafficByReqStatusDataPoint" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/traffic-by-node:
    get:
      tags: [Analytics]
      operationId: analyticsTrafficByNode
      summary: Traffic by edge node
      description: Requests and egress bytes per edge node, split by cache status.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/NodeFilter"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-node totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TrafficByNodeStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/origins:
    get:
      tags: [Analytics]
      operationId: analyticsOrigins
      summary: Edge-to-origin request statistics
      description: |
        How each of your origin addresses is performing as seen from the edge —
        request counts, failures, server errors and upstream latency — with the
        per-node split that produced them.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/NodeFilter"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-origin statistics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/OriginStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/user-agents:
    get:
      tags: [Analytics]
      operationId: analyticsUserAgents
      summary: Traffic by user-agent category
      description: |
        Request counts by broad client category, and only for categories that saw traffic: `Search Engine Bots`, `AI/LLM Crawlers`, `Mobile Devices`, `Desktop Browsers` and `Scripted Agents` — five fixed buckets, so at most five rows, uncapped only because there is nothing more to return.

        Classification is substring matching on the raw user-agent string in that order, first match wins, so Googlebot on a phone is a search-engine bot and Chrome on Android is `Mobile Devices` and never `Desktop Browsers`: this is not a device split. Nothing is verified — a scraper that claims to be Googlebot is counted as Googlebot; the checked classification lives on raw log rows (`bot_kind` and `bot_verified` on `/analytics/logs`, which needs the `logs` feature). `Scripted Agents` is the leftover bucket, holding curl, unrecognised apps and requests that sent no user-agent header at all.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Per-category request counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/UserAgentCategoryStats" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/logs:
    get:
      tags: [Analytics]
      operationId: analyticsLogs
      summary: Raw request logs
      description: |
        Individual request records, newest first, with every filter applied as
        an AND. Requires a plan including the `logs` feature.

        Header and body fields are retained for a shorter window than the rest
        of the row, so older entries return them empty.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - name: limit
          in: query
          description: Rows per page, 1–500. Values outside the range fall back to 100.
          schema: { type: integer, default: 100, minimum: 1, maximum: 500 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
        - name: status
          in: query
          description: |
            One exact status code (`404`), or a whole class written `4xx` /
            `5xx`, which matches 400-499 / 500-599. Only the first character of
            an `xx` value is read, and it goes into the query as a digit: a
            non-digit there is not rejected, it breaks the query and the
            endpoint answers 500.
          schema: { type: string }
        - { name: method, in: query, description: HTTP method — case-insensitive., schema: { type: string } }
        - name: uri
          in: query
          description: |
            Substring of the request URI. The stored URI is percent-encoded
            exactly as the client sent it, and the filter tries both the raw
            column and its decoded form, so a readable non-ASCII string and its
            already-encoded equivalent find the same rows.
          schema: { type: string }
        - { name: cache, in: query, description: "Cache status: `hit`, `miss` or `bypass`.", schema: { type: string, enum: [hit, miss, bypass] } }
        - { name: reqStatus, in: query, description: "Serving path: `cache`, `proxied` or `direct`.", schema: { type: string, enum: [cache, proxied, direct] } }
        - { name: rayId, in: query, description: Exact ray id of a single request., schema: { type: string } }
        - name: "hostname"
          in: query
          description: "Exact host, a subdomain of it, or a bare subdomain label."
          schema: { type: string }
        - name: originHost
          in: query
          description: |
            Case-insensitive substring of the `Host` header the edge sent
            upstream. Matched inside the JSON of the edge-to-origin request
            headers, under that key specifically, so another header carrying the
            same text cannot produce a false hit.
          schema: { type: string }
        - name: originSni
          in: query
          description: |
            Case-insensitive substring of the SNI presented to the origin, read
            the same way as `originHost` - from the `Nsn-Origin-Sni` key of the
            origin request headers.
          schema: { type: string }
        - name: originAddr
          in: query
          description: |
            Substring of the resolved origin address the edge connected to. It
            is stored as `ip:port`, so a bare `1.2.3.4` also matches
            `1.2.3.4:443`.
          schema: { type: string }
        - name: originAddrs
          in: query
          description: |
            Comma-separated list, OR'd together - but despite the name this is
            not the plural of `originAddr`. Each value is matched as a
            case-insensitive substring anywhere in the origin request-header
            JSON, not against the origin address column, so a value can hit on a
            header that is not an address at all.
          schema: { type: string }
        - name: remoteAddr
          in: query
          description: |
            Substring of the visitor's IP, so a prefix like `10.0.` or a partial
            IPv6 address works.
          schema: { type: string }
        - name: country
          in: query
          description: |
            Visitor country, derived from the client IP, as an exact ISO 3166-1
            alpha-2 code. Upper-cased for you, so `ir` and `IR` are the same
            filter.
          schema: { type: string }
        - name: nodeCountry
          in: query
          description: |
            ISO country of the edge node that served the request. Resolved
            against the node inventory first and then matched on those node
            names, so a country with no nodes returns an empty page rather than
            an error.
          schema: { type: string }
        - name: node
          in: query
          description: |
            Edge node name, from `name` in `GET /analytics/nodes`. Matched
            exactly, case-insensitively, and never as a substring: `ir` does not
            also match `ir-2`.
          schema: { type: string }
        - name: threat
          in: query
          description: |
            `waf` is any request that scored above zero, `bot` any request
            classified as a bot, `action` any request a detection rule acted on,
            and `any` the union of the three. An unrecognised value applies no
            filter at all rather than erroring, so a typo returns everything.
          schema: { type: string, enum: [waf, bot, action, any] }
        - name: detectAction
          in: query
          description: |
            The action a detection rule took, matched exactly against the
            lower-cased action name - unless the value contains a `%`, which
            turns it into a SQL LIKE pattern.
          schema: { type: string }
        - name: botKind
          in: query
          description: Substring of the classified bot kind, case-sensitive.
          schema: { type: string }
        - name: wafRuleId
          in: query
          description: |
            Substring of the row's fired-rule-id list, not an exact id match, so
            `94` also matches a row that fired `942100`. Pass the whole id.
          schema: { type: string }
        - name: headerSearch
          in: query
          description: |
            Case-insensitive substring across the captured client request
            headers only. The origin request, origin response and client
            response headers are not searched.
          schema: { type: string }
        - name: uriPatterns
          in: query
          description: |
            Comma-separated URI globs, OR'd against each other while every other
            filter is AND'd. `*` is the wildcard; a pattern without one matches
            as a prefix, so `/api` means "starts with /api" and never
            "contains /api".
          schema: { type: string }
        - name: recordId
          in: query
          description: |
            Narrow to one proxied DNS record. A value that is not a number is
            ignored rather than rejected, so a typo silently widens the result
            back to the whole domain.
          schema: { type: integer }
        - name: md
          in: query
          description: |
            Markdown-for-Agents view. `converted` keeps requests whose response
            was converted, `failed` those where a conversion was attempted and
            failed, `any` either. Unrecognised values apply no filter.
          schema: { type: string, enum: [converted, failed, any] }
      responses:
        "200":
          description: A page of request logs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LogsResponse" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/waf-logs:
    get:
      tags: [Analytics]
      operationId: analyticsWafLogs
      summary: WAF event logs
      description: |
        Requests the WAF evaluated, with the rules that fired and the score they
        produced. Entries where `dryRun` is true were logged only — the request
        was not actually blocked.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - name: limit
          in: query
          schema: { type: integer, default: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - { name: hostname, in: query, description: Substring match on hostname., schema: { type: string } }
        - { name: action, in: query, description: The action taken., schema: { type: string } }
        - { name: ruleId, in: query, description: A CRS rule id that fired., schema: { type: string } }
        - { name: clientIp, in: query, schema: { type: string } }
        - { name: country, in: query, schema: { type: string } }
        - { name: rayId, in: query, schema: { type: string } }
        - { name: blocked, in: query, description: Restrict to blocked or non-blocked requests., schema: { type: boolean } }
      responses:
        "200":
          description: A page of WAF events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WafLogEntry" }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AnalyticsPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /analytics/markdown-tester:
    get:
      tags: [Analytics]
      operationId: analyticsMarkdownTester
      summary: Preview Markdown-for-Agents conversion
      description: |
        Fetches one page twice — once normally and once with
        `Accept: text/markdown` — and returns both responses so you can compare
        them. Ownership is checked but there is no plan gate: you may preview
        the conversion before enabling `markdown_for_agents` on the domain.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - name: hostname
          in: query
          description: Which hostname to fetch. Defaults to the domain apex.
          schema: { type: string }
        - name: path
          in: query
          description: Path to fetch.
          schema: { type: string, default: "/" }
      responses:
        "200":
          description: Both fetches, side by side.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MarkdownTesterResult" }
        "400":
          description: Missing `domain`, or an invalid hostname or path.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /analytics/query:
    post:
      tags: [Analytics]
      operationId: analyticsQuery
      summary: Run a custom query over your request logs
      description: |
        Runs a read-only SQL `SELECT` against the `requests` table — your raw
        request log — for analyses the dedicated endpoints do not cover.

        **Scoping is enforced by the database engine**, not by your query: a
        filter restricting rows to the domains this key can access is appended
        to every read of `requests`. You cannot read another account's traffic,
        however the query is written.

        Restrictions:

        * A single statement only, starting with `SELECT` or `WITH`.
        * Only the `requests` table may be read. Common table expressions you
          define yourself are fine; other tables and any `db.table` reference
          are rejected.
        * Writes, DDL and settings changes are rejected.
        * Execution is capped at 30 seconds and 10 000 returned rows —
          `truncated` tells you when the cap was hit.

        Useful `requests` columns: `event_time`, `domain_id`, `hostname`,
        `method`, `uri`, `status`, `bytesIn`, `bytesOut`, `duration` (ms),
        `remoteAddr`, `country`, `asn`, `asnOrg`, `userAgent`, `cacheStatus`,
        `reqStatus`, `isWS`, `protocol`, `referer`, `originStatus`,
        `originAddr`, `error`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sql]
              properties:
                sql:
                  type: string
                  description: The query to run.
                  examples:
                    - "SELECT toStartOfHour(event_time) AS h, count() AS c FROM requests WHERE status >= 500 GROUP BY h ORDER BY h"
      responses:
        "200":
          description: Query result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyticsQueryResult" }
        "400":
          description: |
            The query was rejected by validation, or the database refused it.
            `detail` carries the underlying message when the engine rejected it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  detail: { type: string }
              examples:
                disallowedTable:
                  value:
                    error: "querying \"system.parts\" is not allowed; only the 'requests' table may be read"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            Read-only key, or the account has no domains whose logs could be
            queried.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  # ---------------------------------------------------------------------------
  # Uptime
  # ---------------------------------------------------------------------------

  /uptime:
    get:
      tags: [Uptime]
      operationId: listOutageIncidents
      summary: List outage incidents
      description: |
        The domain's sustained origin-outage incidents, most recent first. An
        incident opens when a subdomain's origin-error rate stays above the
        domain's threshold for the whole detection window, and resolves after
        `recover_min` clear minutes.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Incident history.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OutageIncident" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /uptime/live:
    get:
      tags: [Uptime]
      operationId: getUptimeLive
      summary: Current origin-error status per subdomain
      description: |
        What is happening right now, per subdomain, over the domain's detection
        window — including hosts that are erroring but have not (yet) crossed
        the alert thresholds. Distinct from `/uptime`, which lists only
        sustained outages.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Live status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeLive" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /uptime/settings:
    get:
      tags: [Uptime]
      operationId: getUptimeSettings
      summary: Get outage-detection settings
      description: |
        The domain's detection thresholds, with the valid range for each in
        `bounds`.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Current settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeSettings" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Uptime]
      operationId: updateUptimeSettings
      summary: Update outage-detection settings
      description: |
        Partial update — omitted fields keep their current value.

        Out-of-range values are rejected, not clamped. A `threshold_pct` outside
        50–100, a `window_min` outside `bounds.window_min_min`–`window_min_max`,
        a `min_requests` below 1, a `min_active_min` outside 1–`window_min`, or
        a `recover_min` outside 1–30 fails the whole request with `400`, naming
        the offending field, and nothing is written. The one value the server
        adjusts silently is `min_active_min`: if the update lowers `window_min`
        below it, it is pulled down to match, because it can never exceed the
        window.

        Requires `analytics.view`, not `domain.settings`. The handler resolves
        the domain with the same permission the uptime reads use, and every role
        has `analytics.view` — so a `viewer`, who can change nothing else on the
        domain, can switch outage alerts off or move the thresholds far enough
        that no incident ever opens. Treat `viewer` on a domain as "can silence
        outage alerting" and grant it accordingly.

        A disabled domain does not block this write either. The disabled-domain
        guard only covers configuration permissions and `analytics.view` is not
        one of them, so there is no `409` here.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UptimeSettingsUpdate" }
      responses:
        "200":
          description: |
            The settings as stored, with the valid range for each in `bounds`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeSettings" }
        "400":
          description: |
            The `domain` query parameter is missing, the body is malformed, or a
            value is outside its bound — for example
            `threshold_pct must be between 50 and 100`. Nothing is saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Recommendations
  # ---------------------------------------------------------------------------

  /recommendations:
    get:
      tags: [Recommendations]
      operationId: listRecommendations
      summary: Get the domain's advisory checklist
      description: |
        Per-domain advice derived from analytics, configuration and live probes.
        Items with `status: ok` are passing checks; `warn` items suggest an
        action. Dismissed items are still returned, flagged `dismissed: true`.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Checklist items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Recommendation" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /recommendations/count:
    get:
      tags: [Recommendations]
      operationId: countRecommendations
      summary: Count outstanding recommendations
      description: How many items need action — excluding dismissed and passing ones.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Outstanding count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /recommendations/dismiss:
    post:
      tags: [Recommendations]
      operationId: dismissRecommendation
      summary: Dismiss a recommendation
      description: |
        Hides one checklist item for the calling user on this domain. Dismissals
        are per user, not per domain — they do not affect other members.
        Repeating the call is a no-op.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key:
                  type: string
                  description: The recommendation's `key`.
      responses:
        "200":
          description: Dismissed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400":
          description: Missing `domain`, or missing `key` in the body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Recommendations]
      operationId: undismissRecommendation
      summary: Restore a dismissed recommendation
      description: |
        Deletes your dismissal row for one `key` on this domain, so the item goes
        back to `dismissed: false` in `GET /recommendations` and, when its status is
        `warn`, is counted again by `/recommendations/count`. The `key` travels in a
        JSON body even though this is a `DELETE`, and the domain is still the
        `?domain=` query parameter. Only your own view changes — dismissals are per
        user and per domain, so dismissing never hid the item from other members.
        The key is not checked against the live checklist: an unknown key, or one
        you never dismissed, deletes nothing and still returns `200`.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key: { type: string }
      responses:
        "200":
          description: Restored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400":
          description: Missing `domain`, or missing `key` in the body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Cache
  # ---------------------------------------------------------------------------

  /cache/stats/:
    get:
      tags: [Cache]
      operationId: getCacheStats
      summary: Cached entry count and size for a domain
      description: |
        The domain's live cache footprint, summed across every storage node.
        Results are cached briefly, so a purge can take a few seconds to show
        up here. Returns zeroes when the cache layer is not enabled.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Cache footprint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries: { type: integer }
                  size_bytes: { type: integer }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/cache/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    delete:
      tags: [Cache]
      operationId: purgeDomainCache
      summary: Purge the entire cache for a domain
      description: |
        Removes every cached entry for the domain.

        The sweep runs in the background: a `202` means it was queued and
        `deleted` is not yet known. Requires `cache.edit` and a plan including
        cache purge.
      responses:
        "200":
          description: Purge completed synchronously — nothing was cached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurgeResult" }
        "202":
          description: Purge queued; it runs in the background.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurgeResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Read-only key, insufficient role, or cache purge is not on the plan.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The domain is disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/cache/keys:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Cache]
      operationId: listCacheKeys
      summary: Browse cached entries
      description: |
        A page of the domain's individual cached objects.

        Note the two host/path pairs on each row: `host` and `path` are the
        human-readable request URL, while `hostname` (the storage namespace) and
        `store_path` are the stored identity you must echo back when purging a
        specific row. Requires `domain.view`.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 100, minimum: 1, maximum: 500 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
        - name: sort
          in: query
          description: Sort column. Anything else falls back to `cached_at`.
          schema: { type: string, enum: [size, host, hostname, path, expires_at, cached_at] }
        - name: dir
          in: query
          schema: { type: string, enum: [asc, desc] }
        - name: hostname
          in: query
          description: Exact match on the storage namespace host.
          schema: { type: string }
        - name: host
          in: query
          description: Match on the request host — substring, or a `*` wildcard.
          schema: { type: string }
        - name: node
          in: query
          description: Edge node that cached the entry. Case-sensitive as stored.
          schema: { type: string }
        - name: path
          in: query
          description: Match on the request path — substring, or a `*` wildcard.
          schema: { type: string }
      responses:
        "200":
          description: A page of cached entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheKeysPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CacheRegistryUnavailable" }

  /domains/{domain}/cache/keys/summary:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Cache]
      operationId: getCacheKeysSummary
      summary: Cache totals with per-node breakdown
      description: |
        Entry count and byte size for the domain, broken down by the edge node
        that cached each entry. Accepts the same filters as
        `/domains/{domain}/cache/keys`. Requires `domain.view`.
      parameters:
        - { name: hostname, in: query, schema: { type: string } }
        - { name: host, in: query, schema: { type: string } }
        - { name: node, in: query, schema: { type: string } }
        - { name: path, in: query, schema: { type: string } }
      responses:
        "200":
          description: Totals.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CacheTotals" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CacheRegistryUnavailable" }

  /domains/{domain}/cache/keys/purge:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Cache]
      operationId: purgeCacheKeys
      summary: Purge or refresh selected cached entries
      description: |
        Targets specific entries — either by listing them in `entries`, or by
        matching a `filter`. Supply one or the other.

        * `mode: "delete"` (default) removes the entry and drops it from the
          listing.
        * `mode: "refresh"` only evicts the stored copy, so the next visitor
          re-fills it. The row stays and updates itself.

        `entries` must carry each row's **stored** identity — copy `hostname`,
        `store_path`, `key_hash` and `node` straight from the listing (note the
        request body uses camelCase for these). Entries belonging to another
        domain are rejected.

        `truncated` is `true` when a filter matched more entries than one call
        may touch — repeat the call until it is `false`. Requires `cache.edit`
        and a plan including cache purge.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CachePurgeKeysRequest" }
      responses:
        "200":
          description: Purge result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CachePurgeKeysResult" }
        "400":
          description: Malformed body, or neither `entries` nor `filter` supplied.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Read-only key, insufficient role, or cache purge is not on the plan.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The domain is disabled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CacheRegistryUnavailable" }


  # ---------------------------------------------------------------------------
  # Sharing
  # ---------------------------------------------------------------------------

  /domains/{domain}/members:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Sharing]
      operationId: listDomainMembers
      summary: List domain members
      description: |
        Everyone with access to the domain, including the owner, plus your own
        role and whether you may manage membership.
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MemberList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/members/{userId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - name: userId
        in: path
        required: true
        description: The member's user id, from the member list.
        schema: { type: integer }
    patch:
      tags: [Sharing]
      operationId: updateDomainMember
      summary: Change a member's role or notifications
      description: |
        Partial update. Changing `role` requires `members.manage`; a member may
        change their own notification preferences without it.

        The owner's role cannot be changed, and the owner always receives every
        notification category.

        One member is not editable here at all: the domain's service provider,
        which appears in the member list with role `provider`. That row is a
        projection of the account the provider manages, not a grant someone
        made, so a role change on it is refused with `409` and a reconciliation
        pass would undo it anyway; its notification preferences may be changed
        only by the provider himself, and anyone else gets `403`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberUpdate" }
      responses:
        "200":
          description: |
            The stored membership row — not the enriched shape that
            `GET /domains/{domain}/members` returns. It carries `id`,
            `domain_id`, `user_id`, `role`, the three `notify_*` flags,
            `invited_by`, `created_at` and `updated_at`, and it does NOT carry
            `email`, `name`, `is_owner`, `is_self` or `joined_at`. Re-list the
            members if you need those.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Membership" }
        "400":
          description: |
            Invalid user id or body, nothing to update, an invalid role, or an
            attempt to change the owner's role or notifications.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or member not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            The target is the domain's service provider and the body changes
            `role`. Provider access follows the managed account, not a
            membership, so it cannot be re-roled from here.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Sharing]
      operationId: removeDomainMember
      summary: Remove a member
      description: |
        Revokes the member's access. The owner cannot be removed. Requires
        `members.manage`.
      responses:
        "200":
          description: Removed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400":
          description: Invalid user id, or an attempt to remove the owner.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or member not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invites:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Sharing]
      operationId: listDomainInvites
      summary: List pending invitations
      description: |
        Invitations that have not yet been accepted. Invites addressed to
        someone who already has access are filtered out. Requires
        `members.manage`.
      responses:
        "200":
          description: Pending invitations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  invites:
                    type: array
                    items: { $ref: "#/components/schemas/Invite" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Sharing]
      operationId: createDomainInvite
      summary: Invite someone to the domain
      description: |
        Creates an invitation and emails it.

        An invite is bound to the address it was sent to: forwarding the email
        does not let someone else accept it. Requires `members.manage`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InviteCreate" }
      responses:
        "200":
          description: Invitation created, with its accept link.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invite" }
        "400":
          description: |
            Invalid body, an invalid role, a missing or malformed email, an
            attempt to invite yourself, or an attempt to invite the domain's
            owner.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: That user is already a member.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invites/{inviteId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/InviteId"
    delete:
      tags: [Sharing]
      operationId: revokeDomainInvite
      summary: Revoke an invitation
      description: Requires `members.manage`.
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or invitation not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invites/{inviteId}/resend:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/InviteId"
    post:
      tags: [Sharing]
      operationId: resendDomainInvite
      summary: Resend an invitation
      description: |
        Refreshes the invitation's expiry and emails it again. Share links
        cannot be resent. Requires `members.manage`.
      responses:
        "200":
          description: Resent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invite" }
        "400":
          description: The invite is a share link, or is no longer active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or invitation not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /invites/{token}:
    parameters:
      - $ref: "#/components/parameters/InviteToken"
    get:
      tags: [Sharing]
      operationId: getInvite
      summary: Inspect an invitation
      description: |
        What an invitation grants, for the authenticated caller. Use it before
        accepting to show who invited them and to which domain.

        `email_match` reports whether the invitation was addressed to the
        calling account — `POST /invites/{token}/accept` will refuse when it is
        false. When it is false, `invited_email` carries the masked target
        address.

        If the caller already has access, `already_member` is true and the
        remaining fields describe their existing role.
      responses:
        "200":
          description: Invitation details.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InvitePreview" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: The invitation does not exist, has expired, was revoked, or is used up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /invites/{token}/accept:
    parameters:
      - $ref: "#/components/parameters/InviteToken"
    post:
      tags: [Sharing]
      operationId: acceptInvite
      summary: Accept an invitation
      description: |
        Joins the domain with the role the invitation carries.

        The invitation binds to the address it was sent to, so accepting from a
        different account fails with `403` and `code: "invite_email_mismatch"`.
        Accepting when you already have access is a no-op that returns
        `already_member: true`.
      responses:
        "200":
          description: Accepted, or you already had access.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InviteAcceptResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            Read-only key, or the invitation was sent to a different email
            address.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InviteMismatch" }
        "404":
          description: The invitation does not exist, has expired, was revoked, or is used up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Billing (read-only)
  #
  # API keys may read billing state but can never move money. The purchase,
  # switch, auto-renew and wallet top-up endpoints reject every key with 403.
  # All monetary amounts are in Iranian rials.
  # ---------------------------------------------------------------------------

  /wallet:
    get:
      tags: [Billing]
      operationId: getWallet
      summary: Get wallet balance
      description: |
        `negative_since` is set while the balance is below zero. If it stays
        negative past the grace window, paid domains are suspended; it clears as
        soon as the balance is non-negative again.
      responses:
        "200":
          description: Wallet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  wallet: { $ref: "#/components/schemas/Wallet" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/transactions:
    get:
      tags: [Billing]
      operationId: listWalletTransactions
      summary: List wallet transactions
      description: |
        The wallet ledger, newest first. `amount_rials` is signed: positive is a
        credit, negative a debit. Traffic charges carry the domain they are
        attributed to.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Ledger rows.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WalletTransaction" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/transactions/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getWalletTransaction
      summary: Get one wallet transaction
      description: |
        The ledger row, plus — for a traffic charge — the per-domain byte and
        cost breakdown of that billing window. A traffic charge is billed once
        per account per window, summed across all your domains, so `by_domain`
        is how you attribute it.
      responses:
        "200":
          description: Transaction detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletTransactionDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such transaction on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/topup-result:
    get:
      tags: [Billing]
      operationId: getTopupResult
      summary: Look up a top-up payment result
      description: |
        The outcome of a wallet top-up, by payment gateway authority. Reading is
        allowed; starting a top-up is not available to API keys.
      parameters:
        - name: authority
          in: query
          required: true
          description: The payment gateway authority returned when the top-up started.
          schema: { type: string }
      responses:
        "200":
          description: Payment outcome.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  ref_code: { type: string }
                  amount_rials: { type: integer }
        "400":
          description: Missing `authority`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such payment on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/period-statement:
    get:
      tags: [Billing]
      operationId: getWalletPeriodStatement
      summary: Get the current billing-period statement
      description: |
        A live estimate for the billing period in progress: plan price plus
        traffic accrued so far, per domain.
      parameters:
        - name: subscription_id
          in: query
          description: Which subscription's period to report. Defaults to the current one.
          schema: { type: integer }
      responses:
        "200":
          description: Period statement.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PeriodStatement" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /wallet/period-statements:
    get:
      tags: [Billing]
      operationId: listWalletPeriodStatements
      summary: List completed billing-period statements
      description: |
        A period statement is the frozen record of one *closed* billing period of one
        subscription — the plan price, the period's traffic bytes per tier, the rials
        actually charged for that traffic, and the per-GB rates that were in force —
        written once when the period closes at renewal, at a manual extension, or on
        the drop into grace. It is deliberately not a tax document: no number, no VAT,
        `is_estimate` false and `not_a_tax_invoice` true. The numbered counterpart of
        the same close is the `period`-kind row in `GET /invoices`, which itemizes only
        the overage actually debited from the wallet. Rows span every subscription on
        the account, newest `period_end` first; `limit` defaults to 50 and is clamped
        to 1–200, and there is no offset, so the newest 200 statements are all you can
        reach. `subscription_id` is accepted but never applied — filter on each row's
        own `subscription_id` yourself.
      parameters:
        - name: subscription_id
          in: query
          schema: { type: integer }
        - name: limit
          in: query
          schema: { type: integer }
      responses:
        "200":
          description: Completed statements, newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/PeriodStatement" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /subscriptions:
    get:
      tags: [Billing]
      operationId: listSubscriptions
      summary: List your subscriptions
      description: Every subscription across your domains.
      responses:
        "200":
          description: Subscriptions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /subscriptions/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getSubscription
      summary: Get one subscription
      description: |
        `{id}` is the numeric subscription id from `GET /subscriptions`, never a
        domain: subscriptions are per domain, and every purchase, switch and renewal
        leaves its own row, so `expired`, `cancelled` and superseded ones stay
        readable here indefinitely. The lookup is bound to your own `user_id`, so a
        subscription on a domain that was merely shared with you is a `404` — read
        that one through `GET /domains/{domain}/subscription`, which admits shared
        members. The payload is an envelope rather than a bare subscription:
        `subscription` (carrying `effective_features`, plus the quota window while it
        is active or in grace), `period_statement`, and `invoices` for this
        subscription, newest `id` first. `period_statement` is the newest frozen
        statement of *this* subscription; if it never closed one and is still active
        or in grace, the field falls back to a live estimate built for the domain's
        current subscription, so compare the statement's `subscription_id` with
        `{id}` before attributing it. In every other case it is `null`.
      responses:
        "200":
          description: Subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such subscription on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /subscriptions/current:
    get:
      tags: [Billing]
      operationId: getCurrentSubscriptionDeprecated
      summary: Current subscription (removed)
      deprecated: true
      description: |
        **Removed.** Subscriptions are per domain. Always returns `410`; use
        `GET /domains/{domain}/subscription` instead.
      responses:
        "410":
          description: Endpoint removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, const: subscription_moved_to_domain }
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /features:
    get:
      tags: [Billing]
      operationId: getAccountFeatures
      summary: Plan summary per domain
      description: One row per domain, with the plan it is on and when that plan expires.
      responses:
        "200":
          description: Per-domain plan summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  domains:
                    type: array
                    items: { $ref: "#/components/schemas/DomainPlanSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /traffic-usage:
    get:
      tags: [Billing]
      operationId: getAccountTrafficUsage
      summary: Traffic usage and pricing across your account
      description: |
        Recent daily traffic rows (up to 90) with totals, plus the current
        per-gigabyte prices.

        Traffic is billed in three tiers — `cached` (served from cache),
        `proxied` (fetched through the edge proxy) and `direct` — each priced
        separately. Older rows may carry only the legacy `bypass_bytes` column;
        those are folded into the direct total.
      responses:
        "200":
          description: Usage rows, totals and prices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountTrafficUsage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /invoices:
    get:
      tags: [Billing]
      operationId: listInvoices
      summary: List invoices
      description: |
        Scoped to your own `user_id`: an invoice is raised against the domain's owner,
        so a domain shared with you contributes none of them here. Ordered by `id`
        descending — issue order — and paginated with `limit` (default 200, which is
        also the hard cap) and `offset` (default 0); an unparseable or out-of-range
        value falls back to the default instead of erroring. Line `items` come with
        every row and `domain_name` is resolved, except on top-up invoices, which have
        no domain at all. The kinds you can see are `subscription` (plan purchase or
        renewal), `period` (traffic overage for a closed billing period), `topup`
        (wallet credit, the one kind exempt from VAT) and `manual` (raised by
        support); the internal per-window traffic invoices are filtered out. Status is
        `paid` on everything the system issues — invoices are receipts written after
        the wallet has already been debited, with `paid_at` stamped — and `cancelled`
        appears only after support cancels one; no current code path issues an
        `unpaid` invoice.
      responses:
        "200":
          description: Invoices, newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /invoices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getInvoice
      summary: Get one invoice
      description: The invoice with its line items.
      responses:
        "200":
          description: Invoice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such invoice on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/subscription:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: getDomainSubscription
      summary: Get the domain's current subscription
      description: |
        The active subscription, its period statement and its invoices. Returns
        `null` when the domain has no subscription. Readable by shared members,
        not only the owner.
      responses:
        "200":
          description: Current subscription, or `null`.
          content:
            application/json:
              schema:
                oneOf:
                  - type: "null"
                  - type: object
                    properties:
                      subscription: { $ref: "#/components/schemas/Subscription" }
                      period_statement: { $ref: "#/components/schemas/PeriodStatement" }
                      invoices:
                        type: array
                        items: { $ref: "#/components/schemas/Invoice" }
        "400":
          description: Missing domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/subscriptions:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: listDomainSubscriptions
      summary: List the domain's subscription history
      description: Owner only.
      responses:
        "200":
          description: Subscriptions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/subscriptions/{id}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getDomainSubscriptionById
      summary: Get one of the domain's subscriptions
      description: Owner only.
      responses:
        "200":
          description: Subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or subscription not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/features:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: getDomainFeatures
      summary: Get the domain's effective plan entitlements
      description: |
        What this domain's plan actually allows — the resolved values after any
        per-subscription overrides, so this is the authority on whether a
        feature is available.

        A limit of `null` means unlimited. Use this before calling a gated
        endpoint rather than inferring capability from the plan name. Readable
        by shared members.
      responses:
        "200":
          description: Effective entitlements.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainFeatures" }
        "400":
          description: Missing domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/traffic-usage:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: getDomainTrafficUsage
      summary: Get the domain's traffic usage
      description: |
        Daily traffic rows for this domain, with totals and current prices.
        Same three-tier model as the account-wide endpoint. Readable by shared
        members.
      responses:
        "200":
          description: Usage rows, totals and prices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountTrafficUsage" }
        "400":
          description: Missing domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invoices:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: listDomainInvoices
      summary: List the domain's invoices
      description: Owner only.
      responses:
        "200":
          description: Invoices, newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/invoices/{id}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Billing]
      operationId: getDomainInvoice
      summary: Get one of the domain's invoices
      description: Owner only.
      responses:
        "200":
          description: Invoice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain or invoice not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Support
  # ---------------------------------------------------------------------------

  /tickets:
    get:
      tags: [Support]
      operationId: listTickets
      summary: List your support tickets
      description: Your tickets, most recently updated first.
      responses:
        "200":
          description: Tickets.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/TicketListItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Support]
      operationId: createTicket
      summary: Open a support ticket
      description: |
        Send JSON for a text-only ticket, or `multipart/form-data` to attach
        images.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, message]
              properties:
                subject: { type: string }
                message: { type: string }
          multipart/form-data:
            schema:
              type: object
              required: [subject, message]
              properties:
                subject: { type: string }
                message: { type: string }
                files:
                  type: array
                  description: Image attachments.
                  items: { type: string, format: binary }
      responses:
        "201":
          description: |
            The ticket and its opening message, in an envelope. The body is
            `{"ticket": …, "first_message": …}` — not a bare ticket — and the
            status is `201`, not `200`. Both the JSON and the
            `multipart/form-data` branch answer this way. `ticket.messages` is
            empty here; the opening message is `first_message`, and any images
            you uploaded come back on `first_message.attachments`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TicketCreated" }
        "400":
          description: Invalid body, or a missing/oversized subject or message.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tickets/unread-count:
    get:
      tags: [Support]
      operationId: getTicketUnreadCount
      summary: Count tickets with unread replies
      description: |
        Counts tickets, not messages: your `open` tickets whose newest message is from
        staff and whose id is above the last-read message id recorded for you. A
        thread with five unread staff replies therefore counts once, a thread where
        you replied last never counts, and a `closed` ticket never counts even when
        its final staff reply was never read. The only thing that marks a ticket read
        is `GET /tickets/{id}`, which records the thread's highest message id; there
        is no separate mark-read call, and listing tickets computes `is_unread` per
        row without marking anything. The scope is the ticket owner's account, so a
        key sees exactly the tickets its owner opened — nothing here is per domain,
        and support's own unread counter is a different endpoint.
      responses:
        "200":
          description: Unread count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tickets/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Support]
      operationId: getTicket
      summary: Get a ticket with its messages
      description: |
        Fetching a ticket marks it read for you, so the unread count drops.
      responses:
        "200":
          description: The ticket, including its message thread.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ticket" }
        "400":
          description: Invalid id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such ticket on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /tickets/{id}/messages:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [Support]
      operationId: addTicketMessage
      summary: Reply to a ticket
      description: |
        Send JSON for a text-only reply, or `multipart/form-data` to attach
        images.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string }
          multipart/form-data:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string }
                files:
                  type: array
                  items: { type: string, format: binary }
      responses:
        "200":
          description: The created message.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TicketMessage" }
        "400":
          description: Invalid id, invalid body, or an empty/oversized message.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "404":
          description: No such ticket on this account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Account
  # ---------------------------------------------------------------------------

  /proxy-ip/:
    get:
      tags: [Account]
      operationId: getProxyIp
      summary: Get the edge proxy IP
      description: |
        The IP address to point DNS at for an externally-hosted zone. For
        managed domains NSIN sets this automatically when you mark a record
        proxied.
      responses:
        "200":
          description: Proxy IP.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ip: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }


components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer nsin_…`. The token is an NSIN API key, not a JWT.
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        `X-Api-Key: nsin_…`. Equivalent to the bearer form — use whichever suits
        your client.

  parameters:

    DomainName:
      name: domain
      in: path
      required: true
      description: |
        The domain **name** (for example `example.com`) — not a numeric id.
      schema: { type: string }
      example: example.com

    DomainQuery:
      name: domain
      in: query
      required: true
      description: |
        The domain **name** (for example `example.com`). These endpoints take the
        domain as a query parameter rather than a path segment.
      schema: { type: string }
      example: example.com

    Period:
      name: period
      in: query
      description: |
        Time window, ending now. Buckets are hourly up to `24h` and daily for
        `7d` and `30d`. An unrecognised value falls back to `24h`.
      schema:
        type: string
        enum: ["3h", "6h", "12h", "24h", "7d", "30d"]
        default: "24h"

    HostnameFilter:
      name: hostname
      in: query
      description: |
        Narrow to one subdomain. Matches the exact host, any subdomain of it, or
        a bare label — so `example.com` matches `api.example.com`, and `api`
        matches `api.example.com`, but `exam` matches neither.
      schema: { type: string }

    PathFilter:
      name: path
      in: query
      description: Narrow to a URL path prefix.
      schema: { type: string }

    NodeFilter:
      name: node
      in: query
      description: Narrow to one edge node. Use `name` from `GET /analytics/nodes`.
      schema: { type: string }

    RuleId:
      name: ruleId
      in: path
      required: true
      description: Numeric id of the rule.
      schema: { type: integer }

    InviteId:
      name: inviteId
      in: path
      required: true
      description: Numeric id of the invitation.
      schema: { type: integer }

    InviteToken:
      name: token
      in: path
      required: true
      description: The invitation token from the accept link.
      schema: { type: string }

    RecordId:
      name: recordId
      in: path
      required: true
      description: Numeric id of the DNS record.
      schema: { type: integer }

  responses:

    Unauthorized:
      description: |
        Missing, malformed, revoked or expired API key, or a key whose owning
        user row is gone. A key whose owning account has merely been deactivated
        is not this: that is `403` with `code: account_suspended`, because the
        credential itself is intact and re-issuing it changes nothing.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            invalidKey:
              value: { error: "invalid API key" }

    Forbidden:
      description: |
        The key is read-only, your role on the domain lacks the required
        permission, the domain's plan does not include the feature, or the
        account that owns the key has been deactivated — that last one carries
        `code: account_suspended` and can arrive on any endpoint, including ones
        that document no `403` of their own.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    ReadOnlyKey:
      description: |
        The key is read-only and this endpoint is a write. Read-only keys may
        only issue `GET`, `HEAD` and `OPTIONS`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            readOnly:
              value: { error: "read-only API key" }

    DomainNotFound:
      description: |
        No such domain, or it is not visible to this account. Domains you cannot
        access are reported as not found rather than forbidden.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    RuleDomainNotFound:
      description: |
        No such domain, or you hold no role on it at all.

        It no longer means "your role is too low". The rules endpoints resolve
        access through the shared domain guard, which answers `403` when the
        caller can see the domain but lacks `rules.edit`, and `409` with
        `code: domain_disabled` when the domain is switched off and the request
        is a write. Reading is open to every role, so a `GET` can only fail here.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notFound:
              value: { error: "domain not found" }

    RuleNotFound:
      description: |
        The rule does not exist, or it belongs to another domain or another rule
        type — a rule id is always checked against both the domain in the path
        and the type of the endpoint. It also covers a domain that does not
        exist or that you hold no role on.

        It no longer means "your role is too low". A caller who may read the
        domain but not write its rules is refused before the rule is even looked
        up, with `403`; a write against a disabled domain is refused with `409`
        and `code: domain_disabled`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    RuleInvalid:
      description: |
        Malformed body, an invalid field value, or `record_ids` containing a
        record that does not belong to this domain.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            foreignRecords:
              value: { error: "record_ids do not belong to this domain" }

    RulePlanLimited:
      description: |
        The key is read-only, your role on the domain lacks `rules.edit` (a
        `viewer` has `domain.view` and nothing more), or the domain's plan does
        not include this rule type or allows fewer rules of it than you already
        have.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    DomainQueryRequired:
      description: The `domain` query parameter is missing.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            missing:
              value: { error: "domain is required" }

    AnalyticsPlanLimited:
      description: |
        The domain's plan does not include the feature this endpoint needs
        (`monitoring` for most sections, `logs` for raw and top-N request data).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    AnalyticsUnavailable:
      description: |
        The process holds no ClickHouse client at all - the connection was never
        opened at startup. It is built once and never rebuilt, so this state
        lasts until the backend restarts.

        It is NOT what you get when the store is merely down, and that is the
        trap. Startup opens the client and pings it as a separate step; a failed
        ping is logged and the client is kept, so a ClickHouse that was
        unreachable at boot still leaves a usable handle behind and this 503
        stays rare. Every failure at query time answers **500** instead - the
        store unreachable, or a query outrunning its timeout of 10s for windows
        up to `24h`, 20s for `7d`, 45s for `30d` - carrying either
        `{"error": "query failed"}` or a "Could not load analytics right now"
        message.

        So retry on 500 exactly as you retry on 503. On the analytics endpoints
        a 500 nearly always means the store could not answer, not that your
        request was wrong.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            unavailable:
              value: { error: "analytics unavailable" }

    CacheRegistryUnavailable:
      description: The cache registry is temporarily unreachable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    RateLimited:
      description: |
        The key exceeded its request budget (300 requests per minute by default).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            limited:
              value: { error: "rate limit exceeded" }

  schemas:

    Error:
      type: object
      description: |
        The error shape used by every endpoint. `error` is always present. `code`
        is present only on the failures that have one — do not require it, and do
        not parse `error` to recover it.
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.
        code:
          type: string
          description: |
            Stable machine-readable reason. Present on some failures only; the
            wording of `error` may change, this will not.

            * `account_suspended` — `403`. The account behind the credential has
              been switched off, by an admin or by its provider. Every
              authenticated route answers this, so treat it as terminal rather
              than retrying.
            * `domain_disabled` — `409`, not `403`. You have every right to the
              operation; the domain is simply switched off and is not being
              served, so its configuration cannot change. It stays readable, and
              writes work again once it is enabled.
            * `managed_by_reseller` — `403`. The account is a reseller's client
              and this surface belongs to its provider. See *If your account is
              managed by a reseller*.
            * `invite_email_mismatch` — `403` from `POST /invites/{token}/accept`.
              The invitation was addressed to a different email; the body also
              carries `invited_email`, masked.

            A panel session — not an API key — can additionally see
            `session_check_failed` on a `503`, which means the session could not
            be verified, not that it is invalid. Retry it; do not discard the
            token.
          examples:
            - account_suspended
            - domain_disabled
            - managed_by_reseller
            - invite_email_mismatch
      examples:
        - { error: "read-only API key" }
        - { error: "account not active", code: "account_suspended" }

    Message:
      type: object
      properties:
        message: { type: string }
      examples:
        - { message: "deleted" }

    Role:
      type: string
      description: |
        Your role on a domain. `owner` is implicit for the domain's creator and
        for global admins; the other three are grantable via sharing.
      enum: [owner, admin, editor, viewer]

    Permission:
      type: string
      description: One capability on a domain.
      enum:
        - domain.view
        - domain.settings
        - domain.delete
        - records.edit
        - rules.edit
        - cache.edit
        - ssl.manage
        - analytics.view
        - billing
        - members.manage

    DomainStatus:
      type: string
      description: |
        * `pending` — managed domain waiting for its nameservers to point at NSIN.
        * `unverified` — external-DNS domain waiting for its TXT verification record.
        * `active` — serving.
        * `moved` — delegation has left NSIN; the domain keeps serving during a grace window.
        * `disabled` — not serving; re-enable with `POST /domains/{domain}/enable`.
        * `banned` — administratively blocked.
      enum: [pending, unverified, active, moved, disabled, banned]

    Domain:
      type: object
      description: A domain and its edge configuration.
      properties:
        id: { type: integer }
        name: { type: string, examples: ["example.com"] }
        status: { $ref: "#/components/schemas/DomainStatus" }
        dns_mode:
          type: string
          enum: [managed, external]
          description: |
            `managed` — NSIN hosts the DNS zone. `external` — you host DNS
            elsewhere and prove ownership with a TXT record.
        user_id: { type: integer, description: Id of the owning user. }
        verification_started_at: { type: string, format: date-time }
        cache_l2_max_gb:
          type: integer
          description: Per-domain cap on disk (L2) cache size, in GB.
        cache_l2_ttl_days:
          type: integer
          minimum: 1
          maximum: 7
          description: How long a disk-cache entry may live, in days. Maximum 7.
        cache_cap_mb:
          type: integer
          enum: [128, 256, 512, 2048, 4096]
          description: |
            Largest response body NSIN will buffer and cache, in MB. Bigger
            responses stream straight from origin and are never cached. The
            selectable ceiling depends on the domain's plan.
        developer_mode_until:
          type: string
          format: date-time
          description: |
            While set and in the future, the edge bypasses cache reads and writes
            for this domain. Absent when developer mode is off.
        pending_since: { type: string, format: date-time }
        moved_since: { type: string, format: date-time }
        next_check_at:
          type: string
          format: date-time
          description: When the background nameserver checker will next look at this domain.
        last_manual_ns_check_at:
          type: string
          format: date-time
          description: Last user-triggered nameserver check; these are limited to one per hour.
        sec_no_sniff:
          type: boolean
          description: |
            Send `X-Content-Type-Options: nosniff`. Off by default — it can break
            an origin that mislabels asset MIME types.
        sec_referrer_policy:
          type: boolean
          description: "Send `Referrer-Policy: strict-origin-when-cross-origin`."
        sec_strip_headers:
          type: boolean
          description: Strip origin fingerprint headers from responses.
        markdown_for_agents:
          type: boolean
          description: |
            Serve a Markdown rendering of eligible HTML pages to clients sending
            `Accept: text/markdown`. Requires an active plan.
        outage_alerts:
          type: boolean
          description: Notify the owner when a subdomain suffers a sustained origin outage.
        uptime_threshold_pct:
          type: integer
          minimum: 50
          maximum: 100
          description: Per-minute origin-error percentage that counts as "down".
        uptime_window_min:
          type: integer
          minimum: 2
          maximum: 60
          description: Minutes the domain must stay down before an incident opens.
        uptime_min_requests:
          type: integer
          description: Minimum origin-eligible requests in the window — the traffic floor below which no incident opens.
        uptime_min_active_min:
          type: integer
          description: Minimum populated one-minute buckets required in the window.
        uptime_recover_min:
          type: integer
          description: Consecutive clear minutes before an incident resolves.
        suspended:
          type: boolean
          description: |
            Paused for billing. The edge refuses the domain's TLS handshake, so
            visitors get a connection error. Clears automatically once the
            wallet is no longer negative.
        suspended_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    DomainVerification:
      type: object
      description: The TXT record to publish to prove ownership of an external-DNS domain.
      properties:
        host: { type: string, description: Name to create the TXT record at. }
        type: { type: string, const: TXT }
        value: { type: string, description: Exact TXT value to publish. }
        verified: { type: boolean }
        expires_at: { type: string, format: date-time }
        seconds_remaining: { type: integer }

    DomainDetail:
      allOf:
        - $ref: "#/components/schemas/Domain"
        - type: object
          properties:
            verification: { $ref: "#/components/schemas/DomainVerification" }
            nsin_ns:
              type: array
              description: The canonical (first) accepted nameserver set.
              items: { type: string }
            nsin_ns_sets:
              type: array
              description: |
                Every accepted nameserver set. The delegation must match exactly
                one set in full — sets cannot be mixed.
              items:
                type: array
                items: { type: string }
            current_ns:
              type: array
              description: The nameservers currently observed in the parent zone.
              items: { type: string }
            my_role: { $ref: "#/components/schemas/Role" }
            my_permissions:
              type: array
              items: { $ref: "#/components/schemas/Permission" }
            ns_check_interval_seconds:
              type: integer
              description: How often the background checker re-checks the delegation.

    DomainSslSummary:
      type: object
      properties:
        status: { type: string, examples: ["active", "pending", "failed", "missing"] }
        expires_at: { type: string, format: date-time }
        days_remaining: { type: integer }

    DomainWithSsl:
      allOf:
        - $ref: "#/components/schemas/Domain"
        - type: object
          properties:
            ssl: { $ref: "#/components/schemas/DomainSslSummary" }
            subscription:
              type: object
              additionalProperties: true
              description: The domain's active subscription, when it has one.
            verification: { $ref: "#/components/schemas/DomainVerification" }
            my_role: { $ref: "#/components/schemas/Role" }

    DomainCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: The domain to add, without scheme or trailing dot.
          examples: ["example.com"]
        dns_mode:
          type: string
          enum: [managed, external]
          default: managed

    DomainUpdate:
      type: object
      description: |
        Every field is optional; omitted fields are left unchanged.
      properties:
        dns_mode: { type: string, enum: [managed, external] }
        cache_l2_max_gb: { type: integer, minimum: 1 }
        cache_l2_ttl_days: { type: integer, minimum: 1, maximum: 7 }
        cache_cap_mb: { type: integer, enum: [128, 256, 512, 2048, 4096] }
        sec_no_sniff: { type: boolean }
        sec_referrer_policy: { type: boolean }
        sec_strip_headers: { type: boolean }
        markdown_for_agents: { type: boolean }

    DeveloperMode:
      type: object
      properties:
        active: { type: boolean }
        expires_at:
          type: string
          format: date-time
          description: When developer mode switches itself off. Absent when inactive.

    NsCheckResult:
      type: object
      properties:
        ok: { type: boolean, description: Whether the delegation matched an accepted set. }
        status: { $ref: "#/components/schemas/DomainStatus" }
        message: { type: string }
        next_check_at: { type: string, format: date-time }
        nsin_ns:
          type: array
          items: { type: string }
          description: The nameservers the delegation is expected to match.
        current_ns:
          type: array
          items: { type: string }
          description: The nameservers actually observed.

    VerifyResult:
      type: object
      properties:
        verified: { type: boolean, const: true }
        domain: { $ref: "#/components/schemas/DomainDetail" }

    SslCoverageGap:
      type: object
      description: A proxied hostname that the domain's certificate does not cover yet.
      properties:
        hostname: { type: string }
        status: { type: string, enum: [pending, failed, missing] }
        failure_count: { type: integer }
        max_retries: { type: integer }
        next_retry_at: { type: string, format: date-time }
        last_error: { type: string, description: The certificate authority's own reason for the last failure. }

    SslInfo:
      type: object
      properties:
        status: { type: string, examples: ["active", "pending", "failed", "missing"] }
        expires_at: { type: string, format: date-time }
        issued_at: { type: string, format: date-time }
        days_remaining: { type: integer }
        issuer: { type: string }
        subject: { type: string }
        serial_number: { type: string }
        sans:
          type: array
          items: { type: string }
        signature_algorithm: { type: string }
        key_size: { type: integer }
        is_wildcard: { type: boolean }
        auto_renewal: { type: boolean }
        has_private_key: { type: boolean }
        can_manual_issue:
          type: boolean
          description: Whether `POST /domains/{domain}/ssl/issue` would be accepted right now.
        manual_issue_reason:
          type: string
          description: Why manual issuance is unavailable, when `can_manual_issue` is false.
        next_manual_issue_at: { type: string, format: date-time }
        last_issue_attempt_at: { type: string, format: date-time }
        coverage:
          type: array
          description: |
            Proxied hostnames not yet on the certificate. Absent when coverage is
            complete.
          items: { $ref: "#/components/schemas/SslCoverageGap" }

    CustomCertificateUpload:
      type: object
      required: [certificate, private_key]
      properties:
        certificate:
          type: string
          description: |
            PEM-encoded certificate chain. Include intermediates — a leaf-only
            bundle makes clients fail chain verification.
        private_key:
          type: string
          description: PEM-encoded private key matching the certificate.
        hostnames:
          type: array
          items: { type: string }
          description: |
            Which of the certificate's SANs this upload should cover. Use the
            `eligible` list from `POST /domains/{domain}/ssl/parse`.

    CustomCertificateResult:
      type: object
      properties:
        message: { type: string }
        domain: { type: string }
        hostnames:
          type: array
          items: { type: string }
        expires_at: { type: string, format: date-time }
        issued_at: { type: string, format: date-time }
        subject: { type: string }
        sans:
          type: array
          items: { type: string }

    ParsedCertificate:
      type: object
      properties:
        subject: { type: string }
        issuer: { type: string }
        sans:
          type: array
          items: { type: string }
          description: Every SAN on the certificate.
        eligible:
          type: array
          items: { type: string }
          description: The SANs that belong to this domain and may be passed as `hostnames` on upload.
        default_selection:
          type: array
          items: { type: string }
          description: The subset of `eligible` covering the apex and its wildcard.
        expires_at: { type: string, format: date-time }
        issued_at: { type: string, format: date-time }

    RecordType:
      type: string
      enum: [A, AAAA, CNAME, ANAME, NS, TXT, MX, SRV, PTR, CAA, TLSA, SSHFP, URI]

    RecordScheme:
      type: string
      description: |
        Protocol the edge uses to reach the origin for a proxied record.
        `Default` follows the request's own scheme; `Auto` probes.
      enum: [Http, Https, Auto, Default]

    Record:
      type: object
      properties:
        id: { type: integer }
        name:
          type: string
          description: Record name relative to the domain. `@` is the apex.
          examples: ["www", "@"]
        original_name:
          type: string
          description: The fully-qualified name, with trailing dot.
          examples: ["www.example.com."]
        type: { $ref: "#/components/schemas/RecordType" }
        destination:
          type: string
          description: |
            The record's value. For a proxied record this is the **origin** the
            edge connects to, and the published DNS answer is the NSIN proxy IP
            instead — see `dns_content`.
        dns_content:
          type: string
          description: What is actually published in DNS. Equals the proxy IP for proxied records.
        ttl: { type: integer, description: TTL in seconds. }
        proxied:
          type: boolean
          description: |
            Route this hostname through the NSIN edge. Only `A`, `AAAA`, `CNAME`
            and `ANAME` may be proxied.
        captcha: { type: boolean, description: Challenge visitors before passing them to the origin. }
        editable: { type: boolean, description: False for records NSIN manages on your behalf. }
        user_id: { type: integer }
        domain_id: { type: integer }
        scheme: { $ref: "#/components/schemas/RecordScheme" }
        port: { type: integer, description: "Origin port for proxied records. Default: 443." }
        host_header: { type: string, description: Overrides the Host header (and SNI) sent to the origin. }
        monitor: { type: boolean, description: Include this record in uptime monitoring. }
        dest_country:
          type: string
          description: "ISO country code of the destination, detected by NSIN."
        timeout: { type: integer, description: "Upstream timeout in seconds. Default: 15." }
        mx_priority: { type: integer, minimum: 0, maximum: 65535, description: Only meaningful for `MX`. }
        comment: { type: string, maxLength: 1024, description: Free-form note. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    OriginRuleTag:
      type: object
      description: |
        An enabled origin rule that overrides where a proxied record's traffic
        goes, meaning the effective origin is not the record's `destination`.
      properties:
        rule_id: { type: integer }
        type: { type: string, enum: [origin_route, origin_pool] }
        zone_wide:
          type: boolean
          description: True when the rule applies to every proxied record of the domain.
        dry_run: { type: boolean, description: The rule is evaluated but not enforced. }

    RecordWithOriginRules:
      allOf:
        - $ref: "#/components/schemas/Record"
        - type: object
          properties:
            origin_rules:
              type: array
              description: Absent when nothing overrides this record's origin. Routes are listed before pools.
              items: { $ref: "#/components/schemas/OriginRuleTag" }

    RecordCreate:
      type: object
      required: [name, type, destination]
      properties:
        name:
          type: string
          description: Name relative to the domain. Use `@` for the apex.
        type: { $ref: "#/components/schemas/RecordType" }
        destination:
          type: string
          description: "IP address, hostname or text content."
        mx_priority: { type: integer, minimum: 0, maximum: 65535 }
        proxied: { type: boolean, default: false }
        captcha: { type: boolean, default: false }
        scheme: { $ref: "#/components/schemas/RecordScheme" }
        port: { type: integer, default: 443 }
        host_header: { type: string }
        monitor: { type: boolean }
        dest_country: { type: string }
        timeout: { type: integer, default: 15 }
        comment: { type: string, maxLength: 1024 }

    RecordUpdate:
      type: object
      description: Every field is optional; omitted fields keep their current value.
      properties:
        name: { type: string }
        destination: { type: string }
        mx_priority: { type: integer, minimum: 0, maximum: 65535 }
        proxied: { type: boolean }
        captcha: { type: boolean }
        scheme: { $ref: "#/components/schemas/RecordScheme" }
        port: { type: integer }
        host_header: { type: string }
        monitor: { type: boolean }
        timeout: { type: integer }
        comment: { type: string, maxLength: 1024 }

    BatchItemResult:
      type: object
      properties:
        id: { type: integer }
        ok: { type: boolean }
        error: { type: string, description: Present only when `ok` is false. }

    BatchResult:
      type: object
      description: |
        Outcome of a best-effort bulk operation. The status code is `200` even
        when some records failed — inspect `results`.
      properties:
        succeeded: { type: integer }
        failed: { type: integer }
        results:
          type: array
          items: { $ref: "#/components/schemas/BatchItemResult" }

    ImportPreviewRecord:
      type: object
      properties:
        name: { type: string }
        type: { $ref: "#/components/schemas/RecordType" }
        destination: { type: string }
        ttl: { type: integer }
        mx_priority: { type: integer }
        proxied: { type: boolean }
        status:
          type: string
          enum: [new, overwrite, unsupported]
          description: |
            `overwrite` means an NSIN record with the same name and type already
            exists and would be replaced.
        existing_id: { type: integer, description: Set when `status` is `overwrite`. }
        reason: { type: string, description: Why an entry is `unsupported`. }

    ImportRecordItem:
      type: object
      required: [name, type, destination]
      properties:
        name: { type: string }
        type: { $ref: "#/components/schemas/RecordType" }
        destination: { type: string }
        ttl: { type: integer }
        mx_priority: { type: integer }
        proxied: { type: boolean }

    ImportResult:
      type: object
      properties:
        created: { type: integer }
        failed:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              type: { type: string }
              error: { type: string }
        records:
          type: array
          description: The domain's full record list after the import.
          items: { $ref: "#/components/schemas/Record" }

    # -------------------------------------------------------------------------
    # Rules — shared pieces
    # -------------------------------------------------------------------------

    RuleType:
      type: string
      enum:
        [cache, drop, redirect, rewrite, waf, captcha, rate_limit, bot_route,
         origin_pool, origin_route, fingerprint, error_page]

    HostMatchType:
      type: string
      description: |
        How `host_pattern` is matched. The empty string means "no host filter",
        and is the only valid value when `host_pattern` is empty — the two
        fields are set and cleared together.
      enum: ["", exact, wildcard, regex]

    ActionMode:
      type: string
      description: |
        * `enforce` — the rule acts (block, redirect, challenge, …).
        * `dry_run` — the rule matches and is logged as "would have acted", but
          the request reaches the origin unchanged. Use it to test a rule
          safely.

        Not every rule type honours this; cache ignores it.
      enum: [enforce, dry_run]

    RulePathMatchType:
      type: string
      description: How `path_includes` and `path_excludes` are interpreted.
      enum: [wildcard, regex]

    RuleCommon:
      type: object
      description: The fields every rule carries, whatever its type.
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        record_id:
          type: integer
          description: |
            Deprecated single-record scope. Prefer `record_ids`. Absent for
            zone-wide rules.
        record_ids:
          type: array
          items: { type: integer }
          description: |
            The proxied DNS records this rule applies to. Empty or absent means
            zone-wide — every proxied record of the domain.
        type: { $ref: "#/components/schemas/RuleType" }
        enabled: { type: boolean }
        priority:
          type: integer
          description: Evaluation order; lower runs first. Defaults to 100.
        host_pattern:
          type: string
          description: Optional hostname filter. Empty means the rule is not host-scoped.
        host_match_type: { $ref: "#/components/schemas/HostMatchType" }
        action_mode: { $ref: "#/components/schemas/ActionMode" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    RuleCommonBody:
      type: object
      description: |
        The shared rule fields accepted by every create and update body. All are
        optional — on create they fall back to defaults, on update an omitted
        field is left unchanged.
      properties:
        record_id:
          type: [integer, "null"]
          description: Deprecated single-record scope. Prefer `record_ids`.
        record_ids:
          type: array
          items: { type: integer }
          description: |
            Scope the rule to these proxied records. Omit or send an empty array
            for a zone-wide rule. Every id must belong to this domain.
        enabled: { type: boolean, default: true }
        priority: { type: integer, default: 100, minimum: 0 }
        host_pattern: { type: string }
        host_match_type: { $ref: "#/components/schemas/HostMatchType" }
        action_mode: { $ref: "#/components/schemas/ActionMode" }

    RulePathScope:
      type: object
      description: Path matching shared by the rule types that filter on the URL path.
      properties:
        path_match_type: { $ref: "#/components/schemas/RulePathMatchType" }
        path_includes:
          type: array
          items: { type: string }
          description: 'Paths the rule applies to. Defaults to `["/*"]` — everything.'
        path_excludes:
          type: array
          items: { type: string }
          description: Paths carved back out of `path_includes`.

    RuleReorderRequest:
      type: array
      description: A bare array — not wrapped in an object.
      items:
        type: object
        required: [id, priority]
        properties:
          id: { type: integer }
          priority: { type: integer, minimum: 0 }

    RuleReorderResult:
      type: object
      properties:
        updated: { type: integer, description: How many rules were changed. }

    # -------------------------------------------------------------------------
    # Rules — cache
    # -------------------------------------------------------------------------

    CacheScope:
      type: string
      description: |
        What the rule caches among the paths it already matches.

        * `default` — static assets only, chosen by file extension.
        * `everything` — every cacheable response, HTML included.

        There is no "custom" scope: narrow what you cache by scoping
        `path_includes` instead.
      enum: [default, everything]

    CacheRuleFields:
      type: object
      properties:
        ttl_sec:
          type: integer
          description: How long an entry stays fresh, in seconds. `0` uses the default.
        refresh_sec:
          type: integer
          description: |
            Background refresh interval in seconds — the entry is re-fetched
            this often while still being served. `0` disables it.
        with_qs:
          type: boolean
          description: Include the query string in the cache key. Off means `?a=1` and `?a=2` share one entry.
        scope: { $ref: "#/components/schemas/CacheScope" }
        bypass_authorization:
          type: boolean
          default: true
          description: |
            Skip caching requests that carry an `Authorization` header. Leave on
            unless you are certain the response is not user-specific.
        bypass_set_cookie:
          type: boolean
          default: true
          description: |
            Skip caching responses that set a cookie. Turning this off can serve
            one visitor's session to another — only do it for responses you know
            are anonymous.
        respect_client_no_store:
          type: boolean
          default: true
          description: "Honour `Cache-Control: no-store` from the client."
        respect_origin_cache_control:
          type: boolean
          default: true
          description: Honour the origin's `Cache-Control` directives.
        respect_origin_max_age:
          type: boolean
          default: true
          description: Use the origin's `max-age` instead of `ttl_sec`.
        bypass_wp_admin:
          type: boolean
          default: true
          description: Never cache WordPress admin and login paths.

    CacheRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/CacheRuleFields"

    CacheRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/CacheRuleFields"

    # -------------------------------------------------------------------------
    # Rules — drop
    # -------------------------------------------------------------------------

    DropRuleFields:
      type: object
      properties:
        country_match_type:
          type: string
          enum: [include, exclude]
          description: |
            Whether `countries` is the set that IS dropped (`include`) or the
            only set that is NOT dropped (`exclude`).
        countries:
          type: array
          items: { type: string }
          description: ISO 3166-1 alpha-2 country codes. Empty means no country filter.

    DropRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/DropRuleFields"

    DropRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/DropRuleFields"

    # -------------------------------------------------------------------------
    # Rules — redirect
    # -------------------------------------------------------------------------

    RedirectRuleFields:
      type: object
      properties:
        target:
          type: string
          description: Where to send the visitor. Absolute URL, or a path when redirecting within the site.
        status_code:
          type: integer
          enum: [301, 302, 307, 308]
          default: 302
          description: |
            The redirect status. `301`/`308` are permanent and cached hard by
            browsers — verify the rule with `302` first.
        preserve_query:
          type: boolean
          default: true
          description: Append the original query string to `target`.
        preserve_path:
          type: boolean
          description: Append the original path to `target`.

    RedirectRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RedirectRuleFields"

    RedirectRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RedirectRuleFields"

    # -------------------------------------------------------------------------
    # Rules — rewrite
    # -------------------------------------------------------------------------

    RewriteRuleFields:
      type: object
      properties:
        path_target:
          type: string
          description: |
            The path sent to the origin. With `path_match_type: regex` you may
            reference capture groups from `path_includes`.
        query_mode:
          type: string
          enum: [preserve, replace, strip]
          description: |
            * `preserve` — pass the original query string through.
            * `replace` — substitute `query_target`.
            * `strip` — drop the query string entirely.
        query_target:
          type: string
          description: The replacement query string, used when `query_mode` is `replace`.

    RewriteRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RewriteRuleFields"

    RewriteRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RewriteRuleFields"

    # -------------------------------------------------------------------------
    # Rules — WAF
    # -------------------------------------------------------------------------

    WafRuleFields:
      type: object
      properties:
        paranoia:
          type: integer
          minimum: 1
          maximum: 4
          default: 1
          description: |
            OWASP CRS paranoia level. Higher catches more attacks and produces
            more false positives — raise it in `dry_run` first.
        threshold:
          type: integer
          minimum: 1
          maximum: 100
          default: 5
          description: Anomaly score at which a request is blocked.
        body_cap_kb:
          type: integer
          minimum: 0
          maximum: 1024
          default: 128
          description: How much request body to inspect, in KB. `0` skips body inspection.
        rule_excludes:
          type: array
          items: { type: string }
          description: CRS rule ids to disable, for tuning out false positives.

    WafRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/WafRuleFields"

    WafRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/WafRuleFields"

    # -------------------------------------------------------------------------
    # Rules — captcha
    # -------------------------------------------------------------------------

    CaptchaRuleFields:
      type: object
      properties:
        ttl_sec:
          type: integer
          description: How long a solved challenge is remembered for that visitor, in seconds.

    CaptchaRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/CaptchaRuleFields"

    CaptchaRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/CaptchaRuleFields"

    # -------------------------------------------------------------------------
    # Rules — rate limit
    # -------------------------------------------------------------------------

    RateLimitRuleFields:
      type: object
      properties:
        limit:
          type: integer
          description: Requests allowed per `window_sec` for one key.
        window_sec:
          type: integer
          default: 60
          description: Length of the counting window, in seconds.
        key_by:
          type: string
          enum: [ip, ip_path]
          default: ip
          description: |
            How requests are bucketed. `ip` counts everything from one address
            together; `ip_path` counts each path separately per address.
        on_breach:
          type: string
          enum: [drop, captcha]
          default: drop
          description: What happens to requests above the limit.
        burst:
          type: integer
          description: Extra requests tolerated momentarily above `limit`.

    RateLimitRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RateLimitRuleFields"

    RateLimitRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/RateLimitRuleFields"

    # -------------------------------------------------------------------------
    # Rules — bot route
    # -------------------------------------------------------------------------

    BotKind:
      type: string
      description: |
        A bot the edge classifier recognises. `*` matches any of them and is
        accepted in `bot_kinds` even though it is not itself a catalogue entry;
        `generic-bot` is the catch-all user-agent heuristic.
      enum:
        ["*", gptbot, oai-searchbot, chatgpt-user, claudebot, claude-user,
         perplexitybot, perplexity-user, googlebot, google-extended, bingbot,
         ccbot, bytespider, meta-externalagent, amazonbot, applebot,
         duckduckbot, yandexbot, ahrefsbot, semrushbot, mj12bot, generic-bot]

    BotRouteRuleFields:
      type: object
      properties:
        bot_kinds:
          type: array
          items: { $ref: "#/components/schemas/BotKind" }
          description: Which bots this rule matches. Must not be empty.
        require_verified:
          type: boolean
          description: |
            Only match bots whose identity was verified (by reverse DNS or
            published IP ranges), not merely self-declared in the user agent.
        action:
          type: string
          enum: [block, alt_content, alt_origin, tag]
          description: |
            * `block` — refuse the request.
            * `alt_content` — serve `body` with `status` instead of the origin.
            * `alt_origin` — proxy to `alt_dest`:`alt_port` over `alt_scheme`.
            * `tag` — let it through, but tag it in telemetry.
        status:
          type: integer
          default: 200
          description: Status code for `alt_content`.
        body:
          type: string
          description: Response body for `alt_content`.
        alt_dest:
          type: string
          description: Origin address for `alt_origin`.
        alt_port:
          type: integer
          description: Origin port for `alt_origin`.
        alt_scheme:
          type: string
          enum: [http, https]
          description: Scheme used to reach `alt_dest`.

    BotRouteRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/BotRouteRuleFields"

    BotRouteRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/BotRouteRuleFields"

    # -------------------------------------------------------------------------
    # Rules — origin pool
    # -------------------------------------------------------------------------

    OriginEntry:
      type: object
      description: One origin in a pool.
      required: [address]
      properties:
        address: { type: string, description: Origin IP address or hostname. }
        port: { type: integer }
        scheme: { type: string, enum: [http, https] }
        weight:
          type: integer
          description: Relative share of traffic under `round_robin` and `least_load`.
        node_ids:
          type: array
          items: { type: integer }
          description: "Under `lb_type: geo`, the edge nodes that use this origin."
        country:
          type: string
          description: ISO country code of this origin.

    OriginPoolHealthCheck:
      type: object
      properties:
        enabled: { type: boolean }
        path: { type: string, default: "/", description: Probe path. }
        interval_sec: { type: integer, default: 15, description: Seconds between active probes. }
        timeout_sec: { type: integer, default: 5, description: Probe timeout in seconds. }
        unhealthy_threshold:
          type: integer
          default: 3
          description: Consecutive probe failures before an origin is marked down.
        healthy_threshold:
          type: integer
          default: 2
          description: Consecutive probe successes before an origin returns to service.
        eject_sec:
          type: integer
          default: 30
          description: How long a passively ejected origin stays out, in seconds.
        host: { type: string, description: Host header override for the probe. }

    OriginPoolRuleFields:
      type: object
      properties:
        lb_type:
          type: string
          enum: [round_robin, least_load, geo]
          description: |
            How traffic is spread across `origins`. `geo` routes by edge node —
            see `node_ids` on each origin.
        origins:
          type: array
          items: { $ref: "#/components/schemas/OriginEntry" }
        health_check: { $ref: "#/components/schemas/OriginPoolHealthCheck" }
        host_header:
          type: string
          description: Host header (and SNI) sent to the pool's origins.

    OriginPoolRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/OriginPoolRuleFields"

    OriginPoolRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/OriginPoolRuleFields"

    # -------------------------------------------------------------------------
    # Rules — origin route
    # -------------------------------------------------------------------------

    OriginRouteRuleFields:
      type: object
      properties:
        address: { type: string, description: Origin IP address or hostname for the matched paths. }
        port: { type: integer }
        scheme: { type: string, enum: [http, https] }
        host_header: { type: string, description: Host header (and SNI) sent to this origin. }
        country:
          type: string
          description: "ISO country code of the origin, detected by NSIN."

    OriginRouteRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/OriginRouteRuleFields"

    OriginRouteRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/OriginRouteRuleFields"

    # -------------------------------------------------------------------------
    # Rules — fingerprint
    # -------------------------------------------------------------------------

    FingerprintRuleFields:
      type: object
      properties:
        match_ja4:
          type: array
          items: { type: string }
          description: JA4 TLS fingerprints to match.
        match_ja4h:
          type: array
          items: { type: string }
          description: JA4H HTTP fingerprints to match.
        action:
          type: string
          enum: [drop, captcha, tag]
          default: captcha
          description: What to do with a matching request.

    FingerprintRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/FingerprintRuleFields"

    FingerprintRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/FingerprintRuleFields"

    # -------------------------------------------------------------------------
    # Rules — error page
    # -------------------------------------------------------------------------

    ErrorPageRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - type: object
          properties:
            mode:
              type: string
              enum: [nsin, custom, origin]
              description: |
                * `nsin` — the NSIN branded error page.
                * `custom` — your own HTML, from `content`.
                * `origin` — pass the origin's own response through untouched.
            codes:
              type: array
              items: { type: integer }
              description: The status codes this rule covers.
            content:
              type: object
              additionalProperties: { type: string }
              description: |
                Status code (as a decimal string) → HTML. The key `"0"` is the
                fallback used for any covered code without its own page.
                **Only populated when fetching a single rule** — the list
                endpoint omits it.
            content_codes:
              type: array
              items: { type: integer }
              description: |
                Which codes have HTML, ascending (`0` first when present).
                Always populated, including in the list response.
            content_bytes:
              type: object
              additionalProperties: { type: integer }
              description: Byte size per entry in `content`. Empty on the list endpoint.

    ErrorPageRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - type: object
          properties:
            mode: { type: string, enum: [nsin, custom, origin] }
            codes:
              type: array
              items: { type: integer }
            content:
              type: object
              additionalProperties: { type: string }
              description: |
                Replaces the rule's entire HTML set. Keys are decimal status
                codes; `"0"` is the fallback. Omit the field to leave existing
                content untouched.

    # -------------------------------------------------------------------------
    # Analytics
    # -------------------------------------------------------------------------

    OverviewItem:
      type: object
      properties:
        domain_id: { type: integer }
        domain_name: { type: string }
        total_requests: { type: integer }
        total_bandwidth: { type: integer, description: Bytes. }
        unique_visitors: { type: integer }

    OverviewSeriesPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        count: { type: integer }

    DomainsOverviewItem:
      type: object
      properties:
        domain_id: { type: integer }
        domain_name: { type: string }
        total_requests: { type: integer }
        total_bandwidth: { type: integer, description: Bytes. }
        error_rate:
          type: number
          description: "Share of requests that failed, 0–1."
        latest_event:
          type: string
          format: date-time
          description: Most recent request seen for this domain or any subdomain. Null when there is no traffic.
        series:
          type: array
          description: Requests over time, for a sparkline.
          items: { $ref: "#/components/schemas/OverviewSeriesPoint" }

    GlobalSummary:
      type: object
      properties:
        total_requests: { type: integer }
        total_bandwidth: { type: integer, description: Bytes. }
        unique_visitors: { type: integer }
        error_rate:
          type: number
          description: "Share of requests that failed, 0–1."
        cache_hit_rate:
          type: number
          description: "Share of cacheable requests served from cache, 0–1."
        peak_rps: { type: integer, description: Highest request count in any single second of the period. }
        domains: { type: integer, description: How many domains contributed. }

    AnalyticsSummary:
      type: object
      properties:
        total_requests: { type: integer }
        total_bandwidth: { type: integer, description: Bytes. }
        unique_visitors: { type: integer }
        error_rate:
          type: number
          description: "Share of requests that failed, 0–1."
        avg_response_time: { type: number, description: Mean response time in milliseconds. }
        p90:
          type: number
          description: "90th percentile response time, ms."
        p95:
          type: number
          description: "95th percentile response time, ms."
        p99:
          type: number
          description: "99th percentile response time, ms."

    RequestsDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        count: { type: integer }

    BandwidthDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        bytes_in: { type: integer }
        bytes_out: { type: integer }

    OriginBandwidthPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        up: { type: integer, description: Bytes sent to the origin. }
        down: { type: integer, description: Bytes received from the origin. }

    DomainBandwidth:
      type: object
      properties:
        domain: { type: string }
        up: { type: integer }
        down: { type: integer }
        ratio: { type: number, description: "`min(up,down) / max(up,down)`." }
        flagged:
          type: boolean
          description: "Set when `ratio` is close to 1.0, which is unusual for web traffic."

    OriginBandwidthResponse:
      type: object
      properties:
        series:
          type: array
          items: { $ref: "#/components/schemas/OriginBandwidthPoint" }
        domains:
          type: array
          items: { $ref: "#/components/schemas/DomainBandwidth" }

    TunnelSuspect:
      type: object
      properties:
        remote_addr: { type: string }
        network: { type: string, description: The client's network operator (AS organisation). }
        country: { type: string }
        hostname: { type: string }
        transport: { type: string, enum: [ws, grpc, xhttp, http] }
        reqs: { type: integer, description: All requests from this client to this host. }
        tunnel_reqs: { type: integer, description: Requests with opaque payloads. }
        tunnel_paths: { type: integer, description: Distinct paths used — around 1 for a tunnel. }
        up: { type: integer, description: Client-to-edge bytes. }
        down: { type: integer, description: Edge-to-client bytes. }
        balance: { type: number, description: "`min/max` of up and down. Informational only." }
        max_secs:
          type: integer
          description: "Longest single connection, in seconds."
        sample_path: { type: string, description: The heaviest single path. }
        ja4: { type: string, description: TLS fingerprint. }
        ja4h: { type: string, description: HTTP fingerprint. }
        ua: { type: string }

    TunnelSuspectsResponse:
      type: object
      properties:
        suspects:
          type: array
          items: { $ref: "#/components/schemas/TunnelSuspect" }

    TopUri:
      type: object
      properties:
        uri: { type: string }
        request_count: { type: integer }

    TopRequestRow:
      type: object
      description: |
        One ranked row. Which fields are populated depends on the `metric` — the
        rest are omitted.
      properties:
        key:
          type: string
          description: "The ranked value — path, country, user agent, hostname or `AS<number>`."
        label:
          type: string
          description: "Network operator name, for the `networks` metric."
        hostname:
          type: string
          description: "Owning host, for path-based metrics."
        asn: { type: integer }
        requests: { type: integer }
        bytes: { type: integer }
        avg_duration: { type: number, description: Milliseconds. }
        max_duration: { type: number, description: Milliseconds. }

    CountryStats:
      type: object
      properties:
        country: { type: string, description: ISO country code. }
        requests: { type: integer }
        bytes: { type: integer }
        unique_visitors: { type: integer }

    AsnStats:
      type: object
      properties:
        asn: { type: integer }
        asn_org: { type: string, description: Network operator name. }
        requests: { type: integer }
        bytes: { type: integer }

    ProtocolStats:
      type: object
      properties:
        protocol: { type: string, description: "`h1`, `h2`, `h3` or `other`." }
        requests: { type: integer }

    StatusCodeStats:
      type: object
      properties:
        status_code: { type: integer }
        count: { type: integer }

    UnreachableReason:
      type: object
      properties:
        reason: { type: string, description: 'Canonical key, e.g. `origin_closed`.' }
        label: { type: string, description: Short headline. }
        fault: { type: string, enum: [client, origin, network, config], description: Who is responsible. }
        meaning: { type: string, description: Plain-language explanation. }
        count: { type: integer }
        status: { type: integer, description: Representative HTTP status the visitor saw. }

    UserAgentCategoryStats:
      type: object
      properties:
        category: { type: string }
        requests: { type: integer }

    CacheAnalytics:
      type: object
      properties:
        hits: { type: integer }
        misses: { type: integer }
        bypass: { type: integer }
        hit_rate: { type: number, description: "`hits / (hits + misses)`; `0` when there were no cache lookups." }
        bypass_reasons:
          type: object
          additionalProperties: { type: integer }
          description: Why requests bypassed the cache, by reason.

    TrafficByCacheDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        cached_bytes: { type: integer }
        miss_bytes: { type: integer }
        bypass_bytes: { type: integer }

    TrafficByReqStatusDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        cache_bytes: { type: integer }
        proxied_bytes: { type: integer }
        direct_bytes: { type: integer }

    TrafficByNodeStats:
      type: object
      properties:
        node: { type: string }
        node_label: { type: string }
        country: { type: string }
        requests: { type: integer }
        bytes_out: { type: integer }
        cached_bytes: { type: integer }
        miss_bytes: { type: integer }
        bypass_bytes: { type: integer }
        cached_requests: { type: integer }
        miss_requests: { type: integer }
        bypass_requests: { type: integer }

    OriginNodeStats:
      type: object
      properties:
        node: { type: string }
        node_label: { type: string }
        country: { type: string }
        requests: { type: integer }
        failed: { type: integer }
        errors_5xx: { type: integer }
        avg_upstream:
          type: number
          description: "Mean origin response time, ms."
        bytes_down: { type: integer, description: Bytes received from the origin. }

    OriginStats:
      type: object
      properties:
        origin_addr: { type: string }
        requests: { type: integer }
        failed: { type: integer }
        errors_5xx: { type: integer }
        avg_upstream:
          type: number
          description: "Mean origin response time, ms."
        p95_upstream:
          type: number
          description: "95th percentile origin response time, ms."
        bytes_down: { type: integer }
        nodes:
          type: array
          description: The per-edge-node split behind these totals.
          items: { $ref: "#/components/schemas/OriginNodeStats" }

    LogEntry:
      type: object
      description: |
        One request. Header and body fields are retained for a shorter window
        than the rest of the row, so older entries return them empty.
      properties:
        domain_id: { type: integer }
        timestamp: { type: string, format: date-time }
        hostname: { type: string }
        method: { type: string }
        uri: { type: string, description: Percent-encoded exactly as the client sent it. }
        status: { type: integer, description: Status returned to the visitor. }
        remote_addr: { type: string }
        country: { type: string }
        duration: { type: number, description: Total request duration in ms. For WebSockets this spans the whole connection. }
        bytes_in: { type: integer }
        bytes_out: { type: integer }
        cache_status: { type: string, enum: [hit, miss, bypass] }
        bypass_reason: { type: string }
        req_status: { type: string, enum: [cache, proxied, direct] }
        user_agent: { type: string }
        headers: { type: string, description: Request headers as captured by the edge. }
        origin_req_headers: { type: string, description: Headers the edge sent to the origin. }
        origin_headers: { type: string, description: Headers the origin returned. }
        client_resp_headers: { type: string, description: Headers returned to the visitor. }
        body: { type: string }
        is_ws: { type: boolean }
        content_type: { type: string }
        error: { type: string }
        node: { type: string, description: Edge node that served the request. }
        ray_id: { type: string, description: Unique id for this request. }
        protocol: { type: string, description: 'Client-to-edge protocol, e.g. `HTTP/2.0`.' }
        origin_protocol: { type: string, description: Edge-to-origin protocol. Empty on a cache hit. }
        origin_status: { type: integer, description: Status the origin returned. `0` on a cache hit. }
        origin_error_body:
          type: string
          description: "Bounded prefix of the body the origin sent with a 5xx, which the edge replaced with an error page."
        origin_addr: { type: string, description: 'Origin `IP:port` the edge connected to.' }
        tls_version: { type: string }
        tls_cipher: { type: string }
        tls_resumed: { type: boolean }
        content_encoding: { type: string }
        referer: { type: string }
        cache_age: { type: integer, description: Seconds the served object had been cached. }
        asn: { type: integer }
        asn_org: { type: string }
        bot_kind:
          type: string
          description: "Bot classification, when the request was identified as one."
        bot_verified:
          type: boolean
          description: "Whether the bot's identity was verified, rather than merely claimed."
        detect_action: { type: string, description: Action a detection rule took. }
        detect_dry_run:
          type: boolean
          description: "True when the rule was in dry-run, so nothing was enforced."
        waf_score: { type: integer, description: WAF anomaly score. }
        waf_rule_ids: { type: string, description: CRS rule ids that fired. }
        ja4: { type: string }
        ja4h: { type: string }
        md_converted: { type: boolean, description: The response was served as Markdown. }
        md_tokens: { type: integer }
        orig_tokens: { type: integer }
        md_fail_reason: { type: string }

    LogsResponse:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/LogEntry" }
        total:
          type: integer
          description: "Rows matching the filters, before paging."
        limit: { type: integer }
        offset: { type: integer }

    WafLogEntry:
      type: object
      properties:
        ts: { type: string, format: date-time }
        domainId: { type: integer }
        recordId: { type: integer }
        hostname: { type: string }
        node: { type: string }
        rayId: { type: string }
        clientIp: { type: string }
        country: { type: string }
        clientPort: { type: integer }
        method: { type: string }
        uri: { type: string }
        httpVersion: { type: string }
        action: { type: string }
        blocked: { type: boolean }
        dryRun: { type: boolean, description: True when the rule only logged; the request was not blocked. }
        score: { type: integer, description: Anomaly score reached. }
        paranoia: { type: integer }
        threshold: { type: integer }
        status: { type: integer }
        ja4: { type: string }
        ja4h: { type: string }
        userAgent: { type: string }
        headers: { type: string }
        body: { type: string }
        ruleIds:
          type: array
          items: { type: integer }
        messages:
          type: array
          items: { type: string }
        ruleData:
          type: array
          items: { type: string }
        variables:
          type: array
          items: { type: string }
        severities:
          type: array
          items: { type: integer }
        tags:
          type: array
          items: { type: string }

    MarkdownTesterFetch:
      type: object
      properties:
        status: { type: integer }
        content_type: { type: string }
        content_length:
          type: integer
          description: "Full body length observed, before truncation."
        body: { type: string }
        truncated: { type: boolean }
        binary:
          type: boolean
          description: "The body was not valid UTF-8, so `body` is omitted."
        cache_status: { type: string }
        markdown_tokens: { type: integer }
        original_tokens: { type: integer }
        converted: { type: boolean, description: The response came back as `text/markdown`. }
        error: { type: string }

    MarkdownTesterResult:
      type: object
      properties:
        url: { type: string, description: The URL that was fetched. }
        feature_enabled: { type: boolean, description: The domain's `markdown_for_agents` setting at test time. }
        html: { $ref: "#/components/schemas/MarkdownTesterFetch" }
        markdown: { $ref: "#/components/schemas/MarkdownTesterFetch" }

    AnalyticsQueryResult:
      type: object
      properties:
        columns:
          type: array
          description: Column names, in result order.
          items: { type: string }
        rows:
          type: array
          description: One entry per row, keyed by column name.
          items:
            type: object
            additionalProperties: true
        row_count: { type: integer }
        truncated: { type: boolean, description: True when the 10 000-row cap was reached and results were cut short. }

    # -------------------------------------------------------------------------
    # Uptime
    # -------------------------------------------------------------------------

    OutageIncident:
      type: object
      properties:
        id: { type: integer }
        hostname: { type: string }
        state: { type: string, description: '`open` while ongoing, `resolved` once recovered.' }
        ongoing: { type: boolean }
        started_at: { type: string, format: date-time }
        resolved_at: { type: string, format: date-time, description: Absent while the incident is ongoing. }
        duration_seconds: { type: integer }
        peak_err_pct: { type: number, description: Highest origin-error percentage reached. }
        sample_reqs: { type: integer, description: Requests observed over the incident. }

    UptimeLiveStatus:
      type: object
      properties:
        hostname: { type: string }
        requests: { type: integer }
        errors: { type: integer, description: Origin-attributable 5xx responses. }
        error_pct: { type: number, description: Origin-error percentage across the window. }
        down: { type: boolean, description: Currently meets this domain's alert thresholds. }
        incident: { type: boolean, description: An incident is currently open for this host. }

    UptimeLive:
      type: object
      properties:
        window_min:
          type: integer
          description: "Length of the trailing window, in minutes."
        hosts:
          type: array
          items: { $ref: "#/components/schemas/UptimeLiveStatus" }

    UptimeSettingsBounds:
      type: object
      description: Valid range for each configurable field.
      properties:
        threshold_pct_min: { type: integer }
        threshold_pct_max: { type: integer }
        window_min_min: { type: integer }
        window_min_max: { type: integer }
        min_requests_min: { type: integer }
        recover_min_min: { type: integer }
        recover_min_max: { type: integer }

    UptimeSettings:
      type: object
      properties:
        enabled: { type: boolean, description: Whether outage alerts are sent for this domain. }
        threshold_pct: { type: integer, description: Per-minute origin-error percentage that counts as down. }
        window_min: { type: integer, description: Minutes the host must stay down before an incident opens. }
        min_requests:
          type: integer
          description: "Traffic floor — below this, no incident opens."
        min_active_min: { type: integer, description: Minimum populated one-minute buckets required in the window. }
        recover_min: { type: integer, description: Consecutive clear minutes before an incident resolves. }
        bounds: { $ref: "#/components/schemas/UptimeSettingsBounds" }

    UptimeSettingsUpdate:
      type: object
      description: Every field is optional; omitted fields keep their current value.
      properties:
        enabled: { type: boolean }
        threshold_pct: { type: integer }
        window_min: { type: integer }
        min_requests: { type: integer }
        min_active_min: { type: integer }
        recover_min: { type: integer }

    # -------------------------------------------------------------------------
    # Recommendations
    # -------------------------------------------------------------------------

    RecommendationAction:
      type: object
      description: Where to go to act on the recommendation.
      properties:
        label: { type: string }
        feature: { type: string, description: 'Logical target, e.g. `cache`.' }
        query:
          type: object
          additionalProperties: { type: string }
          description: Parameters that pre-filter the target view.

    Recommendation:
      type: object
      properties:
        key: { type: string, description: Stable identifier — pass it to the dismiss endpoints. }
        status: { type: string, enum: [ok, warn], description: '`ok` is a passing check; `warn` needs action.' }
        severity: { type: string, enum: [high, medium, low] }
        category: { type: string, enum: [speed, seo, reachability, security] }
        title: { type: string }
        detail: { type: string }
        stats:
          type: object
          additionalProperties: true
          description: Supporting figures behind the finding.
        action: { $ref: "#/components/schemas/RecommendationAction" }
        dismissed:
          type: boolean
          description: "Dismissed by the calling user. Dismissals are per user, not per domain."

    # -------------------------------------------------------------------------
    # Cache
    # -------------------------------------------------------------------------

    PurgeResult:
      type: object
      properties:
        deleted: { type: integer }
        accepted:
          type: boolean
          description: "The purge was queued to run in the background, so `deleted` is not yet known."

    CacheKeyRow:
      type: object
      description: |
        One cached object. `host`/`path`/`query` are the readable request URL;
        `hostname`/`store_path`/`key_hash`/`node` are the stored identity to
        echo back when purging this specific row.
      properties:
        domain_id: { type: integer }
        domain: { type: string }
        host: { type: string, description: 'Exact request host, e.g. `sub.example.com`.' }
        path: { type: string, description: 'Exact request path, e.g. `/assets/app.js`.' }
        query:
          type: string
          description: "Raw query string, without the leading `?`."
        variant:
          type: string
          description: |
            Cache-key suffix separating this entry from other variants of the
            same URL (device, image format, CORS origin, …). Empty for the plain
            variant.
        method: { type: string }
        node: { type: string, description: Edge node that cached it. }
        hostname: { type: string, description: 'Storage namespace host — `*.example.com` for a wildcard record.' }
        store_path: { type: string, description: Raw stored path. Needed for purging; not for display. }
        key_hash: { type: string }
        cache_key: { type: string }
        l2_key:
          type: string
          description: "Reconstructed storage key, for debugging."
        size: { type: integer, description: Bytes. }
        cached_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }

    CacheKeysPage:
      type: object
      properties:
        rows:
          type: array
          items: { $ref: "#/components/schemas/CacheKeyRow" }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }

    CacheNodeTotal:
      type: object
      properties:
        node: { type: string }
        entries: { type: integer }
        size_bytes: { type: integer }

    CacheTotals:
      type: object
      properties:
        entries: { type: integer }
        size_bytes: { type: integer }
        by_node:
          type: array
          items: { $ref: "#/components/schemas/CacheNodeTotal" }

    CachePurgeTarget:
      type: object
      description: |
        One entry's stored identity. Note the camelCase field names — they differ
        from the snake_case used in the listing response.
      required: [hostname, keyHash, node]
      properties:
        domainId: { type: integer }
        hostname: { type: string, description: The listing's `hostname`. }
        storePath: { type: string, description: The listing's `store_path`. }
        keyHash: { type: string, description: The listing's `key_hash`. }
        node: { type: string, description: The listing's `node`. }

    CachePurgeKeysRequest:
      type: object
      description: Supply either `entries` or `filter`.
      properties:
        mode:
          type: string
          enum: [delete, refresh]
          default: delete
          description: |
            `delete` removes the entry and drops it from the listing;
            `refresh` only evicts the stored copy so the next visitor re-fills it.
        entries:
          type: array
          items: { $ref: "#/components/schemas/CachePurgeTarget" }
        filter:
          type: object
          description: Purge everything matching this filter.
          properties:
            hostname: { type: string }
            node: { type: string }
            path:
              type: string
              description: "Path wildcard, e.g. `/assets/*`."

    CachePurgeKeysResult:
      type: object
      properties:
        deleted: { type: integer }
        mode: { type: string, enum: [delete, refresh] }
        truncated:
          type: boolean
          description: |
            The filter matched more entries than one call may touch. Repeat the
            request until this is false.

    # -------------------------------------------------------------------------
    # Sharing
    # -------------------------------------------------------------------------

    Member:
      type: object
      properties:
        user_id: { type: integer }
        email: { type: string }
        name: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        is_owner: { type: boolean }
        is_self: { type: boolean, description: True for the account this key belongs to. }
        joined_at: { type: string, format: date-time }
        notify_domain: { type: boolean, description: Receive domain status notifications. }
        notify_uptime: { type: boolean, description: Receive outage notifications. }
        notify_ssl: { type: boolean, description: Receive certificate notifications. }

    Membership:
      type: object
      description: |
        The stored membership row, as written. This is what
        `PATCH /domains/{domain}/members/{userId}` returns — the member LIST is
        a different, enriched shape (`Member`), joined against the user account.
        Nothing here identifies the user beyond `user_id`.
      properties:
        id: { type: integer, description: Membership row id, not the user id. }
        domain_id: { type: integer }
        user_id: { type: integer }
        role:
          type: string
          description: |
            The stored role — `admin`, `editor` or `viewer` for a granted
            member, or `provider` on the row that projects the domain's service
            provider. `provider` is not grantable and cannot be set here.
        notify_domain: { type: boolean }
        notify_uptime: { type: boolean }
        notify_ssl: { type: boolean }
        invited_by: { type: integer, description: User who granted the access; 0 when unknown. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    MemberList:
      type: object
      properties:
        members:
          type: array
          items: { $ref: "#/components/schemas/Member" }
        my_role: { $ref: "#/components/schemas/Role" }
        can_edit: { type: boolean, description: Whether you may manage membership on this domain. }

    GrantableRole:
      type: string
      description: |
        A role that may be assigned to a member or invitation. `owner` is not
        grantable — it always follows domain ownership.
      enum: [admin, editor, viewer]

    MemberUpdate:
      type: object
      description: |
        Partial patch — send only what you want to change. At least one field is
        required.
      properties:
        role: { $ref: "#/components/schemas/GrantableRole" }
        notify_domain: { type: boolean }
        notify_uptime: { type: boolean }
        notify_ssl: { type: boolean }

    Invite:
      type: object
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        email: { type: string, description: The address the invitation is bound to. }
        role: { $ref: "#/components/schemas/GrantableRole" }
        invited_by: { type: integer, description: User id of the inviter. }
        expires_at: { type: string, format: date-time }
        max_uses: { type: integer }
        uses: { type: integer }
        revoked_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        link: { type: string, description: The accept URL. Returned when the invitation is created or resent. }
        is_link: { type: boolean, description: True for a shareable link rather than an emailed invitation. }
        exhausted: { type: boolean, description: True when `uses` has reached `max_uses`. }

    InviteCreate:
      type: object
      required: [email, role]
      properties:
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/GrantableRole" }
        expires_in_hours: { type: integer, description: Lifetime of the invitation. Omit for the default. }

    InvitePreview:
      type: object
      properties:
        domain: { type: string }
        role: { $ref: "#/components/schemas/Role" }
        inviter: { type: string, description: Display name of whoever sent it. }
        is_link: { type: boolean }
        email_match:
          type: boolean
          description: |
            Whether the invitation was addressed to the calling account.
            Accepting fails when this is false.
        expires_at: { type: string, format: date-time }
        already_member: { type: boolean, description: You already have access; the other fields describe your existing role. }
        is_owner: { type: boolean }
        invited_email: { type: string, description: Masked target address. Present only when `email_match` is false. }

    InviteAcceptResult:
      type: object
      properties:
        accepted: { type: boolean }
        already_member: { type: boolean, description: Returned instead of `accepted` when you already had access. }
        domain: { type: string }
        role: { $ref: "#/components/schemas/Role" }

    InviteMismatch:
      type: object
      properties:
        error: { type: string }
        code: { type: string, const: invite_email_mismatch }
        invited_email: { type: string, description: Masked address the invitation was actually sent to. }

    # -------------------------------------------------------------------------
    # Billing
    # -------------------------------------------------------------------------

    Wallet:
      type: object
      properties:
        id: { type: integer }
        user_id: { type: integer }
        balance_rials: { type: integer }
        negative_since:
          type: string
          format: date-time
          description: |
            When the balance first went below zero in the current debt cycle.
            Absent while non-negative. Staying negative past the grace window
            suspends paid domains.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WalletTransaction:
      type: object
      properties:
        id: { type: integer }
        wallet_id: { type: integer }
        user_id: { type: integer }
        type: { type: string, enum: [topup, purchase, refund, admin_adjust] }
        amount_rials:
          type: integer
          description: "Signed — positive credits, negative debits."
        balance_after: { type: integer }
        description: { type: string }
        ref_type: { type: string, description: 'What the row refers to — `payment`, `subscription`, `traffic` or `manual`.' }
        ref_id: { type: integer }
        ref_code:
          type: string
          description: "Payment gateway reference, for top-ups."
        created_at: { type: string, format: date-time }
        domain_id: { type: integer, description: Set on traffic charges. }
        domain_name: { type: string, description: Set on traffic charges. }

    TrafficDomainBreakdown:
      type: object
      description: One domain's share of a single traffic charge.
      properties:
        domain_id: { type: integer }
        domain_name: { type: string }
        cached_bytes: { type: integer }
        proxied_bytes: { type: integer }
        direct_bytes: { type: integer }
        bypass_bytes:
          type: integer
          description: "Legacy two-tier column, on historical rows only."
        charged_rials: { type: integer }

    WalletTransactionDetail:
      allOf:
        - $ref: "#/components/schemas/WalletTransaction"
        - type: object
          properties:
            node: { type: string }
            billing_window_key: { type: string, description: Identifies the billing window a traffic charge covers. }
            by_domain:
              type: array
              description: Per-domain contribution to this charge. Traffic charges only.
              items: { $ref: "#/components/schemas/TrafficDomainBreakdown" }
            cached_bytes: { type: integer, description: Total across `by_domain`. }
            proxied_bytes: { type: integer, description: Total across `by_domain`. }
            direct_bytes: { type: integer, description: Total across `by_domain`. }
            bypass_bytes: { type: integer, description: Total across `by_domain`. }

    Subscription:
      type: object
      description: |
        A plan attached to one domain. Entitlements are **not** read from the
        plan directly — use `GET /domains/{domain}/features`, which resolves any
        per-subscription overrides.
      properties:
        id: { type: integer }
        user_id: { type: integer }
        domain_id: { type: integer }
        domain_name: { type: string }
        plan_id: { type: integer }
        plan:
          type: object
          additionalProperties: true
          description: The plan this subscription is on.
        plan_term_id: { type: integer }
        plan_term:
          type: object
          additionalProperties: true
          description: The billing term purchased.
        status: { type: string, description: 'For example `active`, `expired`, `grace` or `cancelled`.' }
        started_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        grace_until: { type: string, format: date-time }
        auto_renew: { type: boolean }
        quota_reset_days:
          type: integer
          description: |
            Traffic-allowance reset cadence, frozen at purchase time so later
            plan changes cannot shift an existing subscriber's quota window.
        is_trial:
          type: boolean
          description: |
            The free trial granted at signup. Downgrades to the free plan on
            expiry rather than entering grace.

    DomainPlanSummary:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        plan_slug: { type: string }
        plan_name: { type: string }
        status: { type: string }
        expires_at: { type: string, format: date-time }

    DomainFeatures:
      type: object
      description: |
        The domain's effective entitlements, after per-subscription overrides.
        A `null` limit means unlimited.
      properties:
        plan_id: { type: integer }
        plan_name: { type: string }
        plan_slug: { type: string }
        has_active_plan: { type: boolean }
        max_records: { type: integer, description: Null means unlimited. }
        max_traffic_gb: { type: integer, description: Null means unlimited. }
        max_rules_per_set: { type: integer, description: Rules allowed per rule type. Null means unlimited. }
        max_cache_cap_mb: { type: integer, description: Ceiling for the domain's `cache_cap_mb`. }
        logs_enabled: { type: boolean, description: Gates the raw-log and top-N analytics endpoints. }
        monitoring_enabled: { type: boolean, description: Gates most analytics sections. }
        rules_enabled: { type: boolean }
        cache_purge_enabled: { type: boolean, description: Gates the cache purge endpoints. }
        custom_ssl_enabled: { type: boolean, description: Gates custom certificate upload. }
        ws_enabled: { type: boolean, description: WebSocket support. }
        host_header_edit_enabled: { type: boolean }
        dedicated_support_enabled: { type: boolean }
        domain_usage:
          type: array
          description: Current usage against the limits above.
          items:
            type: object
            additionalProperties: true
        plan_term_id: { type: integer }
        billing_duration_days: { type: integer }
        quota_reset_days: { type: integer }
        quota_period_start: { type: string, format: date-time }
        quota_period_end: { type: string, format: date-time }

    DomainTrafficUsageRow:
      type: object
      description: One day's traffic for one domain.
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        user_id: { type: integer }
        date: { type: string, format: date }
        billing_window_key: { type: string }
        cached_bytes: { type: integer }
        proxied_bytes: { type: integer }
        direct_bytes: { type: integer }
        bypass_bytes:
          type: integer
          description: |
            Legacy two-tier column, present on historical rows. Folded into the
            direct total for display.
        charged_rials: { type: integer }
        window_start: { type: string, format: date-time }
        window_end: { type: string, format: date-time }
        billed_cached_bytes: { type: integer, description: The portion above the plan's free allowance. }
        billed_proxied_bytes: { type: integer, description: The portion above the plan's free allowance. }
        billed_direct_bytes: { type: integer, description: The portion above the plan's free allowance. }
        charged_cached_rials: { type: integer }
        charged_proxied_rials: { type: integer }
        charged_direct_rials: { type: integer }
        invoice_id: { type: integer }
        processed_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    AccountTrafficUsage:
      type: object
      properties:
        usage:
          type: array
          items: { $ref: "#/components/schemas/DomainTrafficUsageRow" }
        total_cached_bytes: { type: integer }
        total_proxied_bytes: { type: integer }
        total_direct_bytes: { type: integer }
        total_charged_rials: { type: integer }
        cached_price_per_gb: { type: integer, description: Rials per GB of cache-served traffic. }
        proxied_price_per_gb: { type: integer, description: Rials per GB of proxied traffic. }
        direct_price_per_gb: { type: integer, description: Rials per GB of direct traffic. }

    InvoiceItem:
      type: object
      properties:
        id: { type: integer }
        invoice_id: { type: integer }
        description: { type: string }
        quantity: { type: integer }
        unit_price_rials: { type: integer }
        total_rials: { type: integer }

    Invoice:
      type: object
      properties:
        id: { type: integer }
        number:
          type: string
          description: "Human-facing invoice number, sequential per Jalali year."
        user_id: { type: integer }
        domain_id: { type: integer }
        domain_name: { type: string }
        subscription_id: { type: integer }
        payment_id: { type: integer }
        kind: { type: string, enum: [subscription, topup, manual] }
        status: { type: string, enum: [paid, unpaid, cancelled] }
        subtotal_rials: { type: integer }
        tax_rials: { type: integer }
        total_rials: { type: integer }
        issued_at: { type: string, format: date-time }
        paid_at: { type: string, format: date-time }
        notes: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        items:
          type: array
          items: { $ref: "#/components/schemas/InvoiceItem" }

    PeriodDomainTraffic:
      type: object
      properties:
        domain: { type: string }
        cached_bytes: { type: integer }
        proxied_bytes: { type: integer }
        direct_bytes: { type: integer }
        estimated_charged_rials: { type: integer }

    PeriodStatement:
      type: object
      description: |
        One billing period's cost. For the period in progress the traffic
        figures are a running estimate.
      properties:
        id: { type: integer }
        subscription_id: { type: integer }
        plan_id: { type: integer }
        plan_name: { type: string }
        plan_term_id: { type: integer }
        billing_duration_days: { type: integer }
        quota_reset_days: { type: integer }
        period_start: { type: string }
        period_end: { type: string }
        plan_price_rials: { type: integer }
        traffic_cached_bytes: { type: integer }
        traffic_proxied_bytes: { type: integer }
        traffic_direct_bytes: { type: integer }
        traffic_bypass_bytes:
          type: integer
          description: "Legacy two-tier column, on historical periods only."
        traffic_charged_rials: { type: integer }
        by_domain:
          type: array
          items: { $ref: "#/components/schemas/PeriodDomainTraffic" }

    # -------------------------------------------------------------------------
    # Support
    # -------------------------------------------------------------------------

    TicketAttachment:
      type: object
      properties:
        id: { type: integer }
        message_id: { type: integer }
        original_name: { type: string }
        content_type: { type: string }
        size_bytes: { type: integer }
        url: { type: string, description: Where to download the attachment. }

    TicketMessage:
      type: object
      properties:
        id: { type: integer }
        ticket_id: { type: integer }
        author_user_id: { type: integer }
        author:
          type: object
          additionalProperties: true
          description: The message author.
        body: { type: string }
        is_staff: { type: boolean, description: True when written by support staff. }
        attachments:
          type: array
          items: { $ref: "#/components/schemas/TicketAttachment" }
        created_at: { type: string, format: date-time }

    Ticket:
      type: object
      properties:
        id: { type: integer }
        user_id: { type: integer }
        subject: { type: string }
        status: { type: string, description: 'For example `open` or `closed`.' }
        closed_at: { type: string, format: date-time }
        closed_by_user_id: { type: integer }
        user_last_read_message_id: { type: integer }
        staff_last_read_message_id: { type: integer }
        messages:
          type: array
          description: The thread. Populated when fetching a single ticket.
          items: { $ref: "#/components/schemas/TicketMessage" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TicketListItem:
      allOf:
        - $ref: "#/components/schemas/Ticket"
        - type: object
          properties:
            is_unread: { type: boolean, description: There are replies you have not read. }

    TicketCreated:
      type: object
      description: |
        The envelope `POST /tickets` answers with. The two halves come back
        together because the ticket and its first message are written in one
        transaction, and the ticket in this response is not thread-loaded — only
        `GET /tickets/{id}` populates `messages`.
      properties:
        ticket: { $ref: "#/components/schemas/Ticket" }
        first_message: { $ref: "#/components/schemas/TicketMessage" }
