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**, with
    one exception: the **NSIN SSO** endpoints at the end, which are the
    OAuth 2.1 / OpenID Connect provider and authenticate with their own
    credentials (see that section). Create a key 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.

    ### 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` | Moves money. |
    | `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.

    ## 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** — all errors share one shape: `{"error": "human readable message"}`.
      See the `Error` schema.
    * **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.ir
  license:
    name: Proprietary

servers:
  - url: https://api.nsin.ir
    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 — including the
      staged import sessions that let a scan be reviewed before it is written.
  - name: Gateways
    description: Ready-made records you can switch on for a domain in one call.
  - 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.
  - 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: Email Routing
    description: |
      Custom email addresses on a domain (`info@example.com`) forwarded to
      destination addresses you verify by clicking an emailed link. NSIN
      publishes the MX, SPF and DKIM records into the zone and receives and
      forwards the mail; nothing is stored.
  - 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: Notifications
    description: The notification feed — domain, plan and account events.
  - name: Account
    description: Account-wide reads.
  - name: SSO
    description: |
      NSIN SSO — the OAuth 2.1 / OpenID Connect provider that lets another
      application sign users in with their NSIN account. These endpoints do
      **not** take an API key; each one authenticates with its own credential.

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}/billing-member:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    put:
      tags: [Domains]
      operationId: setDomainBillingMember
      summary: Choose whose wallet pays for this domain
      description: |
        Nominates the member whose wallet is charged for this domain — automatic
        renewals and interactive purchases alike — or clears the nomination with
        `user_id: null` so the owner pays again.

        **Owner only** (platform support aside). This is deliberately *not*
        gated on `domain.settings`: a member with the `admin` role holds that
        permission, and an admin member must not be able to point the bill at
        somebody else on a domain they do not own.

        The nominee must already hold an **accepted** membership on this domain.
        A pending invitation is not one — it grants nothing until it is
        accepted, and nominating against it would let an owner charge someone
        who never joined.

        There is no consent step and no pending state: the nomination is in
        force the moment it is written, and the nominee is notified immediately.
        Their escape hatch is leaving the domain. Use `GET /domains/{domain}/payer`
        to show who pays, and what they have, before a purchase.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: [integer, "null"]
                  description: |
                    The member's user id, from `GET /domains/{domain}/members`.
                    `null`, `0` or the owner's own id all clear the nomination —
                    the owner paying is the absence of a nomination, not one of
                    its own.
      responses:
        "200":
          description: |
            The payer after the change. Always populated: with no nomination in
            force it describes the owner.
          content:
            application/json:
              schema:
                type: object
                properties:
                  billing_member: { $ref: "#/components/schemas/BillingMember" }
        "400":
          description: |
            Malformed body, or the nominee has no accepted membership on this
            domain (`code: not_a_member`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingMemberError" }
              examples:
                notAMember:
                  value:
                    error: "that person must accept the domain invitation before they can be set as the payer"
                    code: not_a_member
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            The key is read-only, or you are a member of this domain but not its
            owner.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                notOwner:
                  value: { error: "only the domain owner can choose who pays for this domain" }
        "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}/origin-protocols:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Domains]
      operationId: probeOriginProtocols
      summary: Probe which HTTP versions the origins speak
      description: |
        Makes a TLS handshake (reading the ALPN answer), a QUIC handshake and one
        `HEAD /` request (reading the `Alt-Svc` header) against every distinct
        origin behind the domain's proxied records, from the NSIN control plane,
        and reports whether each speaks HTTP/1.1, HTTP/2 and HTTP/3. HTTP/3
        counts as supported when either the QUIC handshake completed or the
        origin advertised `h3` in `Alt-Svc`; `http3_via` says which. Use it
        before changing `origin_protocol`. Answers younger than 20 seconds are
        reused, so repeated calls do not hammer the origin. Requires `domain.view`.
      responses:
        "200":
          description: Per-origin protocol support.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OriginProtocols" }
        "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 domain's verification record and activates the domain
        when it is found: the CNAME described by `ssl_delegation` (the record
        new domains use — it stays in place afterwards as the domain's
        connection to NSIN and lets NSIN issue its certificate), or the legacy
        TXT described by `verification`. Either is accepted. 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" }

  /domains/{domain}/ssl/delegation/check:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [SSL]
      operationId: checkSslDelegation
      summary: Check the SSL delegation record now
      description: |
        Runs a live check of the `ssl_delegation` CNAME of an external-DNS
        domain — the record that lets NSIN issue and renew its certificate
        automatically — and records the result on the domain. A passing check
        on a domain still awaiting verification also activates it: the record
        proves control of the domain's DNS exactly as the TXT record does.

        The check queries `TXT _acme-challenge.{domain}` through public
        resolvers and looks for this domain's own canary value, so it tests
        what a certificate authority will see end to end. Rate limited to once
        a minute per domain, shared with `verify`. Requires `ssl.manage`.
      responses:
        "200":
          description: The delegation is in place. Issuance proceeds automatically.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SslDelegationCheckResult" }
        "400":
          description: Not an external-DNS domain, or the domain is disabled or banned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "422":
          description: |
            The record was not found, has not propagated, or points at a
            delegation that does not belong to this domain. `ok` is `false` and
            `error` says what was seen.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SslDelegationCheckResult" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # 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.

        On an **external-DNS** domain every proxied record also carries
        `edge_target` (what to publish in your own zone so the name reaches
        NSIN) and the stored verdict of the last edge check (`edge_status`,
        `edge_via`, `edge_detail`, `edge_checked_at`) — see the `Record` schema.
      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/export:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [DNS Records]
      operationId: exportZone
      summary: Export the zone as a BIND master file
      description: |
        Renders the domain's records as a standard BIND zone file, served as a
        download (`text/plain`, `Content-Disposition: attachment`).

        For a proxied record the file carries the **origin** the edge connects
        to, not the NSIN proxy IP that public DNS answers with — the export is
        the zone as you configured it, so it stays usable if you restore it
        anywhere else. Proxy state is preserved out of band, in a trailing
        `; nsin-proxied:true` comment that the import endpoints read back. An
        apex alias (`ANAME`) is written as a `CNAME` plus an `; nsin-aname:true`
        marker, because `ANAME` is not a real DNS type and stops most parsers.
        A row whose rdata is not valid presentation form is emitted as a
        `; SKIPPED` comment rather than dropped silently.

        The `SOA` is synthesized — PowerDNS owns the live one — and its serial
        is derived from the records' last-modified time, so exporting unchanged
        data twice produces a byte-identical file. The `NS` set is written only
        while NSIN is authoritative for the domain; for a domain on external DNS
        the file carries no delegation, so it will not point the zone back at us
        if you feed it to another provider.

        Record comments are never included. Read-only: requires `domain.view`,
        and unlike the write endpoints it stays available while the domain is
        suspended or expired.
      responses:
        "200":
          description: Zone file.
          content:
            text/plain:
              schema:
                type: string
              example: |
                ; BIND zone file exported from Nsin for example.com
                ; Generated 2026-08-31T09:00:00Z
                $ORIGIN example.com.
                $TTL 3600
                example.com.	3600	IN	SOA	ny.nsin.ir. hostmaster.example.com. 2026083101 10800 3600 604800 3600
                example.com.	3600	IN	NS	ny.nsin.ir.
                example.com.	3600	IN	NS	th.nsin.ir.

                example.com.	3600	IN	MX	10 mail.example.net.
                blog.example.com.	300	IN	CNAME	hosted.example.net.
                www.example.com.	120	IN	A	203.0.113.10	; nsin-proxied:true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "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/edge-check:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: edgeCheckRecords
      summary: Re-check that proxied records point to NSIN (external DNS)
      description: |
        For a domain whose DNS is hosted **outside** NSIN, resolves every
        proxied record right now and stores the verdict on each one
        (`edge_status` / `edge_via` / `edge_detail` / `edge_checked_at`).

        A background sweep does the same every 5 minutes (hourly once a
        record is confirmed), and a record is checked as soon as it is created
        or updated, so this is for "I just fixed my DNS, look again". Limited
        to once a minute per domain. A managed-DNS domain answers `400` with
        `error_code: managed_dns` — its proxied records always point here.
      responses:
        "200":
          description: The fresh verdicts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  checked: { type: integer, description: Proxied records looked up. }
                  ok: { type: integer, description: Records that resolve to NSIN. }
                  miss: { type: integer, description: Records that resolve elsewhere or not at all. }
                  records:
                    type: array
                    items: { $ref: "#/components/schemas/Record" }
                  next_manual_check_at:
                    type: string
                    format: date-time
                    description: When this endpoint accepts the next call for this domain.
        "400":
          description: The domain uses NSIN-managed DNS.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429":
          description: Checked less than a minute ago. `retry_after_seconds` says how long to wait.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  retry_after_seconds: { type: integer }

  /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`.

        To review what was found before anything reaches DNS, use
        `POST /domains/{domain}/records/import-sessions` instead.
      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" }


  # ---------------------------------------------------------------------------
  # Record import sessions
  #
  # The review step between "we scanned your old nameservers" and "your zone now
  # holds these records". A scan is STAGED in a session: nothing reaches DNS
  # until it is committed, and a session nobody answers is committed
  # automatically once its `auto_commit_at` deadline passes — a managed domain
  # with an empty zone answers NXDOMAIN for every name, so walking away from the
  # review must not be the same as throwing the records away.
  #
  # A domain has at most one open (`scanning` or `ready`) session. Starting a
  # scan while one is open RETURNS that session instead of refusing, so a
  # retried call can never kick off a second sweep.
  # ---------------------------------------------------------------------------

  /domains/{domain}/records/import-sessions:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [DNS Records]
      operationId: startRecordImportSession
      summary: Start a record import session
      description: |
        Scans the domain's current nameservers — a zone transfer where they
        allow one, otherwise a sweep of common names — into a staging session.
        Nothing is written to the zone. Requires `records.edit`.

        The scan runs in the background: the session comes back `scanning` with
        an empty `records` list, and you poll
        `GET .../records/import-sessions/current` until it turns `ready`.

        Every staged row is `proxied: false`, apex and `www` included. Importing
        never switches proxying on for you — turn it on per row in the commit
        body once you have reviewed what was found.

        **Idempotent.** If the domain already has an open session it comes back
        with `200` and no new scan is started, so a double click or a retry
        cannot start a second sweep.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                source:
                  type: string
                  enum: [manual_scan, domain_create, zone_file]
                  default: manual_scan
                  description: |
                    Recorded on the session so support can answer "where did
                    this import come from" months later. An unrecognised value
                    falls back to `manual_scan`.
      responses:
        "200":
          description: |
            An open session already existed and is returned unchanged. No new
            scan was started.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportSession" }
        "202":
          description: |
            A new session was created and its scan is running. `status` is
            `scanning` and `records` stays empty until it finishes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: The domain is disabled or banned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                disabled:
                  value: { error: "domain is disabled" }
        "429": { $ref: "#/components/responses/ImportScanThrottled" }

  /domains/{domain}/records/import-sessions/current:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [DNS Records]
      operationId: getCurrentRecordImportSession
      summary: Get the domain's open import session
      description: |
        The newest session whose status is `scanning` or `ready` — what you poll
        while a scan runs, and what tells you there are staged records still
        waiting for a decision. Requires `domain.view`.
      responses:
        "200":
          description: The open session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportSession" }
        "204":
          description: |
            No open session: nothing is scanning and nothing is waiting to be
            reviewed. There is no body.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/import-sessions/{sessionId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/ImportSessionId"
    get:
      tags: [DNS Records]
      operationId: getRecordImportSession
      summary: Get one import session
      description: |
        One session in any status, including finished ones — what was found,
        what was committed and when. Requires `domain.view`.

        Sessions are resolved within the domain, so an id belonging to another
        domain is reported as not found.
      responses:
        "200":
          description: The session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/ImportSessionNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [DNS Records]
      operationId: discardRecordImportSession
      summary: Discard an import session
      description: |
        The "skip for now" path: the staged rows are dropped, nothing is written
        to the zone, and the auto-commit is cancelled — somebody who explicitly
        skipped the review must not get a surprise import hours later. Requires
        `records.edit`.

        The records themselves are untouched at the old provider; run another
        scan whenever you want them.
      responses:
        "200":
          description: Discarded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  discarded: { type: boolean }
                  records_dropped:
                    type: integer
                    description: How many staged rows were thrown away.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/ImportSessionNotFound" }
        "409":
          description: |
            The session is no longer open — it has already been committed,
            superseded by a rescan, or discarded. The body carries its current
            `status`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportSessionConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/import-sessions/{sessionId}/commit:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/ImportSessionId"
    post:
      tags: [DNS Records]
      operationId: commitRecordImportSession
      summary: Commit a reviewed import session
      description: |
        Writes the reviewed set into the domain's records and into DNS. Requires
        `records.edit`, and the session must be `ready`.

        * `mode: "selected"` with `records` — commits exactly what you post.
          This is the reviewed path: send back the rows you kept, with the
          `proxied` flags you chose.
        * `mode: "all"`, an empty `records`, or **no body at all** — commits the
          session's own preselected rows with the proxy defaults it staged
          (every one of them off).

        An existing record with the same name and type is overwritten. Per-row
        problems come back in `failed` and never abort the batch.

        The session is claimed before anything is written, so a second click
        loses the race and gets `409` rather than importing twice. A whole-import
        failure — the plan's record limit — releases the session back to `ready`,
        so you can upgrade and commit the same staged rows again.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImportSessionCommit" }
      responses:
        "200":
          description: Import outcome, plus the domain's full record list afterwards.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportSessionCommitResult" }
        "400":
          description: |
            Nothing to commit — `mode: "selected"` with no usable rows, or a
            session whose every row was unsupported.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                empty:
                  value: { error: "no records to import" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: The domain has no active plan, so records cannot be created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: |
            Read-only key, insufficient role, or the import would exceed the
            plan's record limit. The session stays `ready` and can be committed
            again after an upgrade.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/ImportSessionNotFound" }
        "409":
          description: |
            The domain is disabled, the session is not `ready`, or somebody
            else — a second click, or the auto-commit — already claimed it. The
            body carries the session's current `status`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportSessionConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/records/import-sessions/{sessionId}/rescan:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/ImportSessionId"
    post:
      tags: [DNS Records]
      operationId: rescanRecordImportSession
      summary: Rescan, superseding an import session
      description: |
        Marks this session `superseded` and starts a fresh one against the
        **same nameservers the original scan read**. Requires `records.edit`.

        Reusing the stored nameservers is the whole point: once the domain is
        delegated to NSIN, resolving its nameservers again finds ours, and a
        rescan would transfer our own still-empty zone and report that the old
        provider had nothing.

        The old session is kept rather than rewritten, so a rescan can never
        race a commit already in flight, and the record of what was originally
        found survives.
      responses:
        "200":
          description: An open session already existed and is returned unchanged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportSession" }
        "202":
          description: The replacement session was created and its scan is running.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/ImportSessionNotFound" }
        "409":
          description: |
            The domain is disabled, or this session is being committed right
            now and cannot be superseded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                committing:
                  value: { error: "this import is being committed" }
        "429": { $ref: "#/components/responses/ImportScanThrottled" }


  # ---------------------------------------------------------------------------
  # Gateways
  #
  # A gateway is a ready-made record NSIN maintains: you pick one from the
  # catalog and we create the record on your domain, with a generated hostname
  # and an origin you do not have to know. The resulting record is not editable
  # — its destination and upstream Host header are ours to set — so it is
  # removed through this endpoint rather than the record delete endpoint.
  #
  # Gateways are managed-DNS only. Switching one on publishes a hostname into
  # the zone NSIN serves, so a domain whose `dns_mode` is `external` cannot have
  # one: switching on and renaming both answer 409 with
  # `error_code: external_dns`. Switching off keeps working, so a domain moved
  # to external DNS can still clear out the gateways it had.
  # ---------------------------------------------------------------------------

  /domains/{domain}/gateways/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Gateways]
      operationId: listGateways
      summary: List gateways
      description: |
        Every gateway currently offered, each with whether it is switched on for
        this domain and, when it is, the record that was created for it —
        together with this domain's gateway plan standing: whether gateways are
        included at all, and how much of the rolling 30-day request allowance
        has been used.
      responses:
        "200":
          description: Gateway list and quota standing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GatewayList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/gateways/terms:
    get:
      tags: [Gateways]
      summary: Gateway terms of use status
      description: |
        Whether this domain has accepted the gateway terms of use, and who
        accepted them. Gateways route traffic over shared egress IP addresses
        that can change, so the terms must be accepted before a gateway can be
        enabled — `POST .../apply` returns 403 with
        `error_code: gateway_terms_required` until they are.

        Acceptance is per DOMAIN, not per user.
      operationId: getGatewayTerms
      parameters:
        - $ref: "#/components/parameters/DomainName"
      responses:
        "200":
          description: Terms status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GatewayTerms" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }

  /domains/{domain}/gateways/terms/accept:
    post:
      tags: [Gateways]
      summary: Accept the gateway terms of use
      description: |
        Records acceptance for this domain. Idempotent — re-accepting keeps the
        original timestamp and accepter. Requires the same permission as
        enabling a gateway.
      operationId: acceptGatewayTerms
      parameters:
        - $ref: "#/components/parameters/DomainName"
      responses:
        "200":
          description: Terms status after acceptance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GatewayTerms" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }

  /domains/{domain}/gateways/{gatewayId}/apply:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/GatewayId"
    post:
      tags: [Gateways]
      operationId: enableGateway
      summary: Switch a gateway on
      description: |
        Creates the gateway's record on this domain and returns it. The record is
        proxied through the NSIN edge and counts against your plan's record
        limit, but it is **not editable** — updating or deleting it through the
        DNS record endpoints returns 403. Use the rename and delete endpoints
        below instead.

        Send a `name` to choose the hostname yourself; omit the body entirely and
        one is generated as `<slug>-<5 digits>`.

        A gateway can be on at most once per domain. Requires `records.edit`.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GatewayName" }
      responses:
        "201":
          description: Gateway switched on; the created record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Record" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            Insufficient permission, no active plan, or the plan's record limit
            is already reached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Domain not found, or no such gateway is on offer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            The domain is disabled, uses external DNS (`error_code:
            external_dns`), or this gateway is already on — in that last case
            the response carries the existing record under `record`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/gateways/{gatewayId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/GatewayId"
    put:
      tags: [Gateways]
      operationId: renameGateway
      summary: Rename a gateway
      description: |
        Changes the hostname of a gateway that is already on, moving it in the
        zone. Only the name changes — the origin and Host header behind the
        gateway stay ours. This is the only way to rename the record, since the
        DNS record endpoints refuse it. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GatewayName" }
      responses:
        "200":
          description: The renamed record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Record" }
        "400":
          description: Missing or invalid name — not a valid hostname label, `@`, or a wildcard.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain not found, or this gateway is not on for it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            The domain is disabled, uses external DNS (`error_code:
            external_dns`), or a record with that name already exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Gateways]
      operationId: disableGateway
      summary: Switch a gateway off
      description: |
        Deletes the record this gateway created and removes it from the zone.
        This is the only way to remove a gateway record. Requires `records.edit`.
      responses:
        "200":
          description: Switched off.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Domain not found, or this gateway is not on for it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "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/header/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listHeaderRules
      summary: List header rules
      description: |
        Adds, overrides and removes HTTP headers — on the request before it
        reaches the origin, on the response before it reaches the visitor, or
        both from the same rule.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Header rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/HeaderRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createHeaderRule
      summary: Create a header rule
      description: |
        Adds, overrides and removes HTTP headers on the request, the response,
        or both. Unlike every other rule type, header rules **compose**: every
        matching rule runs, in priority order.

        Not available on the Free plan, and refused on gateway records.
        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HeaderRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeaderRule" }
        "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/header/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderHeaderRules
      summary: Reorder header rules
      description: |
        Sets the `priority` of several header rules at once. Lower priority
        values are evaluated first, and because header rules compose rather
        than stopping at the first match, order decides which rule wins when
        two of them write the same header name — the later one does. 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/header/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getHeaderRule
      summary: Get a header rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeaderRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateHeaderRule
      summary: Update a header rule
      description: |
        Partial update — omitted fields keep their current value. Sending `ops`
        replaces the whole list; there is no per-op patch. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HeaderRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeaderRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteHeaderRule
      summary: Delete a header 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/header/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleHeaderRule
      summary: Enable or disable a header 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/HeaderRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/optimize/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listOptimizeRules
      summary: List web optimization rules
      description: |
        Shrinks matching responses at the edge: converts JPEG/PNG images to
        WebP, minifies JavaScript and CSS, and sets the brotli level used for
        cached text.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Optimization rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/OptimizeRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createOptimizeRule
      summary: Create a web optimization rule
      description: |
        At least one action must be enabled (`images`, `minify_js`,
        `minify_css`, or a non-zero `compress_level`); a rule that does nothing
        is rejected rather than left to shadow later rules.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OptimizeRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OptimizeRule" }
        "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/optimize/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderOptimizeRules
      summary: Reorder web optimization rules
      description: |
        Sets the `priority` of several optimization 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/optimize/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getOptimizeRule
      summary: Get a web optimization rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OptimizeRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateOptimizeRule
      summary: Update a web optimization rule
      description: |
        Partial update — omitted fields keep their current value. Requires
        `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OptimizeRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OptimizeRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteOptimizeRule
      summary: Delete a web optimization 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/optimize/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleOptimizeRule
      summary: Enable or disable a web optimization 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/OptimizeRule" }
        "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/waf/shield/status:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: getWafShieldStatus
      summary: Default protection status
      description: |
        State of the default NSIN Shield rule — the critical-only WAF rule the
        platform creates for every domain on a plan that includes it. Requires
        `domain.view`.
      responses:
        "200":
          description: Shield status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafShieldStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/rules/waf/shield/restore:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Rules]
      operationId: restoreWafShield
      summary: Restore default protection
      description: |
        Re-creates the default NSIN Shield rule after it was deleted, and
        clears the "dismissed" mark so the platform keeps it provisioned.
        Takes no body. Requires `rules.edit` and a plan that includes default
        protection.
      responses:
        "200":
          description: Shield status after the restore.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WafShieldStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The domain's plan does not include default protection.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "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/basic_auth/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Rules]
      operationId: listBasicAuthRules
      summary: List basic auth rules
      description: |
        Puts an HTTP Basic sign-in prompt in front of the matching paths. A
        request without accepted credentials is answered `401` at the edge and
        never reaches your origin.

        Passwords are write-only: `users` comes back as usernames plus a
        `has_password` flag, never the password or its hash.

        Returned in evaluation order. Requires `domain.view`.
      responses:
        "200":
          description: Basic auth rules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/BasicAuthRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleDomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Rules]
      operationId: createBasicAuthRule
      summary: Create a basic auth rule
      description: |
        Protects the matching paths with the supplied username/password pairs.
        At least one user is required, and every user needs a password of 8–128
        characters.

        Responses on protected paths are never cached at the edge.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BasicAuthRuleBody" }
      responses:
        "200":
          description: Rule created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BasicAuthRule" }
        "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/basic_auth/reorder:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    patch:
      tags: [Rules]
      operationId: reorderBasicAuthRules
      summary: Reorder basic auth rules
      description: |
        Sets the `priority` of several basic auth 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/basic_auth/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    get:
      tags: [Rules]
      operationId: getBasicAuthRule
      summary: Get a basic auth rule
      description: Requires `domain.view`.
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BasicAuthRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Rules]
      operationId: updateBasicAuthRule
      summary: Update a basic auth rule
      description: |
        Partial update — omitted fields keep their current value.

        `users` is the exception: when present it replaces the whole list, so a
        username you leave out is removed. Within it, an entry whose `password`
        is omitted keeps the password that username already has, which is how
        you rename or remove users without retyping everyone's credentials.

        Requires `rules.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BasicAuthRuleBody" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BasicAuthRule" }
        "400": { $ref: "#/components/responses/RuleInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Rules]
      operationId: deleteBasicAuthRule
      summary: Delete a basic auth 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/basic_auth/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/RuleId"
    patch:
      tags: [Rules]
      operationId: toggleBasicAuthRule
      summary: Enable or disable a basic auth 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. Disabling a
        rule removes the sign-in prompt from the paths it covered. Requires
        `rules.edit`.
      responses:
        "200":
          description: The rule in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BasicAuthRule" }
        "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/global-summary/peak:
    get:
      tags: [Analytics]
      operationId: analyticsGlobalSummaryPeak
      summary: Account-wide peak requests per second
      description: |
        The highest number of requests served in any single second of the
        period, across the same domains `/analytics/global-summary` covers.

        This is a full scan of the request log rather than a lookup, so it is
        served on demand and may take a while for long periods. Results are
        cached per account; `all` is refreshed at most once a day.
      parameters:
        - name: period
          in: query
          description: |
            Time window, ending now. Same values as `/analytics/global-summary`,
            plus `all` for the whole retained history. An unrecognised value
            falls back to `24h`.
          schema:
            type: string
            enum: ["3h", "6h", "12h", "24h", "7d", "30d", "all"]
            default: "24h"
      responses:
        "200":
          description: Peak requests per second.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GlobalSummaryPeak" }
        "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/nodes-overview:
    get:
      tags: [Analytics]
      operationId: analyticsNodesOverview
      summary: Traffic by edge node, across your domains
      description: |
        Per-node totals, error and cache rates, latency, a requests-over-time
        series, and the busiest domains on each node — aggregated over every
        domain you can see, or one domain with `domain_id`.

        This is the account-wide counterpart of `/analytics/traffic-by-node`,
        which covers a single domain and splits by cache status instead.

        Two kinds of row need care when reading the list:

        * `node: ""` — requests the serving edge did not stamp with a node name.
          They are counted so the per-node rows still add up to `totals`, but
          they cannot be attributed to a point of presence.
        * `requests: 0` with `registered: true` — a node that is in service but
          served nothing in the period. Kept in the list so a node that stopped
          reporting is visible rather than silently absent.

        `error_rate` and `cache_hit_rate` are percentages (0–100). Durations are
        in milliseconds and exclude WebSocket requests, whose lifetime is the
        whole upgraded connection.
      parameters:
        - $ref: "#/components/parameters/Period"
        - name: domain_id
          in: query
          description: |
            Numeric domain id — note this endpoint scopes by **id**, not by the
            domain name the rest of the API uses. Omit to cover every active
            domain you can see.
          schema: { type: integer }
      responses:
        "200":
          description: Per-node totals with the scope-wide total they add up to.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NodesOverviewResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/AnalyticsUnavailable" }

  /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:
    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/requests-compare:
    get:
      tags: [Analytics]
      operationId: analyticsRequestsCompare
      summary: Requests over time, for period-over-period comparison
      description: |
        Request counts in whole-day windows, for charts that overlay one period
        on another. Unlike `/analytics/requests` — which ends *now* and takes a
        `period` — this window always starts at a local midnight, so every
        bucket covers a complete day and days can be compared like for like.

        * `granularity: hour` returns hourly buckets, meant to be drawn as one
          line per day (hour-by-hour overlay). `days` defaults to 3, max 14.
        * `granularity: day` returns one bucket per day for day-over-day change.
          `days` defaults to 14, max 35 — the ClickHouse row retention, beyond
          which no data exists.

        Scope is one domain (`domain_id`), or — by default — every active domain
        you can see. The response is the same time-series shape as
        `/analytics/requests`; buckets with no traffic are omitted rather than
        zero-filled.
      parameters:
        - name: granularity
          in: query
          description: Bucket size. Anything other than `day` is treated as `hour`.
          schema: { type: string, enum: [hour, day], default: hour }
        - name: days
          in: query
          description: |
            Number of whole days to return, counting back from today. Clamped to
            the granularity's maximum. `0` or omitted uses the default.
          schema: { type: integer, minimum: 1, maximum: 35 }
        - name: domain_id
          in: query
          description: |
            Numeric domain id — note this endpoint scopes by **id**, not by the
            domain name the rest of the API uses. Omit to cover every active
            domain you can see.
          schema: { type: integer }
      responses:
        "200":
          description: Time series.
          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/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
      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
      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)
      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
      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/tls:
    get:
      tags: [Analytics]
      operationId: analyticsTls
      summary: TLS versions, cipher suites and session resumption
      description: |
        What your visitors negotiate with the edge. Every count here is
        restricted to TLS-terminated requests, so plain-HTTP traffic never
        enters the totals — a domain redirecting `:80` to `:443` does not read
        as though a slice of its visitors used no TLS at all.

        `pct` in `versions` is a share of all TLS requests. `pct` in `ciphers`
        is a share of the returned suites only: the list is capped at the top
        12, and the shares are normalised over that list so they still add up
        to 100%.

        This is the visitor-to-edge leg only. The edge-to-origin handshake is
        not reported here.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: TLS mix for the period.
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary: { $ref: "#/components/schemas/TlsSummary" }
                  versions:
                    type: array
                    items: { $ref: "#/components/schemas/TlsVersionStats" }
                  ciphers:
                    type: array
                    description: Top 12 cipher suites, most used first.
                    items: { $ref: "#/components/schemas/TlsCipherStats" }
        "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/ai-crawlers:
    get:
      tags: [Analytics]
      operationId: analyticsAiCrawlers
      summary: AI crawler traffic, in full
      description: |
        Everything the AI Crawl Control pages are built from, in one response:
        period counters, a timeseries, a per-crawler table, and the paths
        crawlers read or were refused.

        `summary`, `series`, `top_paths` and `blocked_paths` cover AI crawlers
        only — the kinds listed in `ai_kinds`, or the single kind named by
        `crawler`. Classic search and SEO crawlers (`googlebot`, `bingbot`,
        `yandexbot`, `ahrefsbot`, `semrushbot`, `mj12bot`, `generic-bot`) are
        deliberately left out of those, so they cannot drown the AI numbers.
        `crawlers` is the exception: it lists **every** bot kind actually seen
        on the domain, so nothing is invisible.

        Human traffic never reaches any of these numbers.

        The Markdown counters in `summary` describe the Markdown-for-Agents
        feature: `markdown_answered` is what the edge actually rewrote to
        Markdown, and `markdown_missed` is the rest of what could plausibly
        have been Markdown (`markdown_eligible` — responses below `300`).
        Redirects, `404`s and images are not counted against the feature.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - name: crawler
          in: query
          description: |
            Narrow every panel except `crawlers` to one bot kind, from the
            canonical catalog — the values in `ai_kinds`, plus `googlebot`,
            `bingbot`, `duckduckbot`, `yandexbot`, `ahrefsbot`, `semrushbot`,
            `mj12bot` and `generic-bot`. Omit for all AI crawlers.
          schema: { type: string }
          example: gptbot
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: AI crawler activity for the period.
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary: { $ref: "#/components/schemas/AiCrawlerSummary" }
                  crawlers:
                    type: array
                    description: Every bot kind seen, busiest first — not limited to AI crawlers.
                    items: { $ref: "#/components/schemas/AiCrawlerStats" }
                  series:
                    type: array
                    items: { $ref: "#/components/schemas/AiCrawlerDataPoint" }
                  top_paths:
                    type: array
                    description: Top 10 paths AI crawlers read successfully.
                    items: { $ref: "#/components/schemas/AiCrawlerPath" }
                  blocked_paths:
                    type: array
                    description: |
                      Top 10 paths AI crawlers asked for and did not get (`4xx`
                      or `5xx`) — the content agents want but cannot cite.
                    items: { $ref: "#/components/schemas/AiCrawlerPath" }
                  ai_kinds:
                    type: array
                    description: The bot kinds counted as AI crawler traffic.
                    items: { type: string }
        "400":
          description: The `domain` query parameter is missing, or `crawler` is not a known bot kind.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                unknownCrawler:
                  value: { error: "unknown crawler" }
        "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
      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/bot-cache:
    get:
      tags: [Analytics]
      operationId: analyticsBotCache
      summary: Cache counters for verified search-engine crawlers
      description: |
        The cache section restricted to requests from **verified**
        search-engine crawlers (Googlebot, Bingbot, Applebot, DuckDuckBot,
        YandexBot) — the traffic the domain's bot cache serves — split per
        crawler. A spoofed User-Agent never counts: "verified" is the edge's own
        verdict against the operator's published IP ranges or reverse DNS.
        With the bot cache off the numbers show what crawlers get from the
        ordinary cache, which is the baseline the feature improves.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
        - $ref: "#/components/parameters/HostnameFilter"
        - $ref: "#/components/parameters/PathFilter"
      responses:
        "200":
          description: Crawler cache counters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BotCacheAnalytics" }
        "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
      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
      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/gateways:
    get:
      tags: [Analytics]
      operationId: analyticsGateways
      summary: Requests per gateway
      description: |
        Request counts for each gateway currently switched on for the domain,
        with a zero-filled series over the period — one point per bucket whether
        or not traffic landed in it.

        Which hostnames count as gateways is resolved server-side from the
        domain's records, so a gateway switched off drops out of this response
        along with its history.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: One entry per enabled gateway. Empty when none are on.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/GatewayStat" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /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: Exact HTTP status code., schema: { type: string } }
        - { name: method, in: query, description: HTTP method — case-insensitive., schema: { type: string } }
        - { name: uri, in: query, description: URI substring match., 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: Host header sent to the origin., schema: { type: string } }
        - { name: originSni, in: query, description: SNI presented to the origin., schema: { type: string } }
        - { name: originAddr, in: query, description: Origin address the edge connected to., schema: { type: string } }
        - { name: originAddrs, in: query, description: Comma-separated list of origin addresses., schema: { type: string } }
        - { name: remoteAddr, in: query, description: Client IP address., schema: { type: string } }
        - { name: country, in: query, description: Client ISO country code., schema: { type: string } }
        - { name: nodeCountry, in: query, description: ISO country of the edge node that served the request., schema: { type: string } }
        - { name: node, in: query, description: Edge node name., schema: { type: string } }
        - { name: threat, in: query, description: Threat category., schema: { type: string } }
        - { name: detectAction, in: query, description: Action a detection rule took on the request., schema: { type: string } }
        - { name: botKind, in: query, description: Classified bot kind., schema: { type: string } }
        - { name: wafRuleId, in: query, description: A CRS rule id that fired., schema: { type: string } }
        - { name: headerSearch, in: query, description: Substring searched across the captured headers., schema: { type: string } }
        - { name: uriPatterns, in: query, description: Comma-separated URI patterns., schema: { type: string } }
        - { name: path, in: query, description: URL path prefix., schema: { type: string } }
      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 watched scope's origin-error rate stays above the
        domain's threshold for the whole detection window, and resolves after
        `recover_min` clear minutes.

        Incidents come from two sources: the domain's default whole-host watch,
        and each uptime monitor. Read `monitor_id` / `scope_label` to tell them
        apart.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
        - name: limit
          in: query
          required: false
          description: How many incidents to return, 1-200. Out-of-range values fall back to the default.
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      responses:
        "200":
          description: Incident history.
          content:
            application/json:
              schema:
                type: object
                properties:
                  incidents:
                    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/active:
    get:
      tags: [Uptime]
      operationId: getActiveOutages
      summary: Outage incidents open right now
      description: |
        A one-glance answer to "is anything down?" — the incidents currently
        open for this domain. Cheaper than `/uptime/live` (it reads only the
        incident records, no traffic aggregation), so it is the endpoint to poll
        for a status indicator.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      responses:
        "200":
          description: Active outages.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeActive" }
        "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 watched scope, 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.

        One hostname may appear on several rows: the whole-host watch
        (`monitor_id: 0`) plus one row per uptime monitor that matches it, each
        measured over its own traffic. Key on (`hostname`, `monitor_id`).
      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. Values are
        clamped to the ranges reported in `bounds`. Requires `domain.settings`
        (not analytics read access: retuning detection is a settings change).

        Narrowing `uptime_host_includes` / `uptime_host_excludes` can strand an
        open incident on a subdomain that is no longer watched. The detector
        closes it on its next tick, without a recovery notification.
      parameters:
        - $ref: "#/components/parameters/DomainQuery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UptimeSettingsUpdate" }
      responses:
        "200":
          description: Updated settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeSettings" }
        "400":
          description: |
            The `domain` query parameter is missing, the body is malformed, a
            threshold is out of range, or the host scope is invalid — a match
            type other than `exact`/`wildcard`/`regex` while a host filter is
            set, too many entries, an over-long entry, or a regex that does not
            compile.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                badMatchType:
                  value: { error: "uptime_host_match_type must be 'exact', 'wildcard', or 'regex' when a host filter is set" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /uptime/monitors:
    parameters:
      - $ref: "#/components/parameters/DomainQuery"
    get:
      tags: [Uptime]
      operationId: listUptimeMonitors
      summary: List the domain's uptime monitors
      description: |
        A monitor narrows outage detection to a host+path scope you name, using
        the same thresholds as the whole-host watch. Without one, a failing
        `/checkout` is diluted by every healthy marketing request and never
        crosses the domain-wide threshold.
      responses:
        "200":
          description: The domain's monitors, oldest first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeMonitorList" }
        "400": { $ref: "#/components/responses/DomainQueryRequired" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Uptime]
      operationId: createUptimeMonitor
      summary: Create an uptime monitor
      description: |
        Requires `domain.settings`. A monitor must narrow something — a monitor
        with neither a host scope nor a path scope is just a duplicate of the
        whole-host watch and is rejected. At most `max` monitors per domain
        (see the list endpoint); the cap is a detection-cost ceiling, not a plan
        limit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UptimeMonitorBody" }
      responses:
        "201":
          description: The created monitor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeMonitor" }
        "400": { $ref: "#/components/responses/UptimeMonitorInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /uptime/monitors/{id}:
    parameters:
      - $ref: "#/components/parameters/DomainQuery"
      - name: id
        in: path
        required: true
        schema: { type: integer }
    put:
      tags: [Uptime]
      operationId: updateUptimeMonitor
      summary: Update an uptime monitor
      description: |
        Partial update — omitted fields keep their current value, and an
        explicit empty array clears a list. Requires `domain.settings`.

        Disabling a monitor here closes its open incident silently: you turned
        the watch off, so no recovery notification is sent.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UptimeMonitorBody" }
      responses:
        "200":
          description: The updated monitor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeMonitor" }
        "400": { $ref: "#/components/responses/UptimeMonitorInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/UptimeMonitorNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Uptime]
      operationId: deleteUptimeMonitor
      summary: Delete an uptime monitor
      description: |
        Requires `domain.settings`. The monitor's open incident is resolved
        without a recovery notification; its resolved incident history is kept,
        because it is the record behind alerts you may still be reading.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
        "400":
          description: Invalid monitor id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/UptimeMonitorNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /uptime/monitors/{id}/enabled:
    parameters:
      - $ref: "#/components/parameters/DomainQuery"
      - name: id
        in: path
        required: true
        schema: { type: integer }
    patch:
      tags: [Uptime]
      operationId: setUptimeMonitorEnabled
      summary: Enable or disable an uptime monitor
      description: |
        The cheap toggle behind a monitor list's switch — it changes nothing
        else. Requires `domain.settings`. Disabling closes the monitor's open
        incident silently, with no recovery notification.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
      responses:
        "200":
          description: The updated monitor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UptimeMonitor" }
        "400":
          description: Invalid monitor id, or an invalid body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/UptimeMonitorNotFound" }
        "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`.

        Whether an item can be hidden depends on its severity, and `dismissible`
        says so per item: `low` hides permanently, `medium` for 7 days (after
        which it returns if the problem is still there), and `high` cannot be
        hidden at all.
      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 }
                  computing:
                    type: boolean
                    description: |
                      Present (and `true`) only when the checklist for this
                      domain has not been computed yet: the server has started
                      computing it in the background and `count` is `0` for
                      now. Poll again; the field disappears once the checklist
                      exists. This endpoint never computes the checklist
                      itself — it is polled by the panel's sidebar badge from
                      every page — so its answer is always immediate.
        "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.

        How long it stays hidden depends on the item's severity: a `low` item is
        hidden for good, a `medium` one for 7 days, and a `high` one cannot be
        hidden at all (`409`). Repeating the call on a `medium` item restarts
        its 7 days.
      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" }
        "409":
          description: |
            The item is a `high`-severity warning, which cannot be dismissed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Recommendations]
      operationId: undismissRecommendation
      summary: Restore a dismissed recommendation
      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/content:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Cache]
      operationId: getCacheKeyContent
      summary: Download one cached object
      description: |
        The stored bytes behind a listing row, read straight from cache storage —
        your origin is never contacted, so this works even when the site is down.

        Address the entry by its **stored** identity: copy `hostname`, `node` and
        `key_hash` verbatim from a `/domains/{domain}/cache/keys` row. `node` is
        the edge location that cached it, so the same URL cached at three
        locations is three entries and you choose which copy you get.

        The body is returned in its original uncompressed form with the stored
        `Content-Type`, always as an attachment. The cached response's own status
        code and age travel in `X-Nsin-Cache-Status` and `X-Nsin-Cached-At` — the
        HTTP status describes only whether the read succeeded.

        A `404` with `entry is no longer cached` means the listing row outlived
        the object (it expired, was evicted, or was purged). Requires
        `domain.view`.
      parameters:
        - name: hostname
          in: query
          required: true
          description: The row's `hostname` — the storage namespace, not the request host.
          schema: { type: string }
        - name: node
          in: query
          required: true
          description: The row's `node` — the edge location holding this copy. Case-sensitive.
          schema: { type: string }
        - name: key_hash
          in: query
          required: true
          description: The row's `key_hash`.
          schema: { type: string }
      responses:
        "200":
          description: |
            The stored object. `Content-Type` is whatever was cached; the payload
            is the uncompressed body.
          headers:
            X-Nsin-Cache-Node:
              description: Edge location this copy came from.
              schema: { type: string }
            X-Nsin-Cache-Status:
              description: HTTP status of the cached response.
              schema: { type: integer }
            X-Nsin-Cached-At:
              description: When the object was cached (RFC 3339).
              schema: { type: string, format: date-time }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "400":
          description: Missing or malformed `hostname`, `node` or `key_hash`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Domain or hostname not yours, or the entry is no longer cached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: The stored entry could not be decoded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Cache storage is unavailable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /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/notify-catalog:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Sharing]
      operationId: getMemberNotifyCatalog
      summary: Notification categories, channels and role defaults
      description: |
        The vocabulary behind a member's `notify` matrix: every category, every
        outbound channel, and what each grantable role receives when the member
        has made no explicit choice. Read it instead of hard-coding those lists,
        so a category added later shows up on its own. Requires `domain.view`.

        The in-app feed is not a channel and cannot be switched off
        (`in_app_always_on` is always `true`): the notification row is both the
        feed and the de-duplication key, so suppressing it would make the same
        event re-send on every tick forever.
      responses:
        "200":
          description: The catalog.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotifyCatalog" }
        "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.

        A role change freezes whatever the OLD role was switching on, so a
        demotion cannot silently mute alerts the member was already receiving.
        Categories they never touched still follow the new role.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MemberUpdate" }
      responses:
        "200":
          description: The membership after the change.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MemberUpdateResult" }
        "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" }
        "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
      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
      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
      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, its invoices and the
        wallet movements tied to it. 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" }
                      transactions:
                        type: array
                        description: |
                          Wallet movements belonging to this subscription — the
                          purchase debit, renewal debits and any refunds of its
                          invoices — newest first. Empty when the term was never
                          paid from the wallet.
                        items: { $ref: "#/components/schemas/WalletTransaction" }
        "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}/payer:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Billing]
      operationId: getDomainPayer
      summary: Who pays for this domain, and what they have
      description: |
        The wallet a purchase or a renewal on this domain will actually spend,
        and its balance. The panel shows this on the purchase dialog, and any
        integration that spends on a shared domain should: a nomination
        (`PUT /domains/{domain}/billing-member`) takes effect with no consent
        step, so whoever clicks Buy must see whose money is about to move — and
        an insufficient balance should be visible up front rather than arriving
        as a surprise `402`.

        Requires `billing` on the domain, or being the nominated payer yourself.
        Someone being charged can always see what for, whatever their role.
      responses:
        "200":
          description: The domain's payer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainPayer" }
        "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}/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
        files. See `TicketAttachmentUpload` for what may be attached.

        With `multipart/form-data`, `message` may be empty as long as at least
        one file is attached; with JSON it is required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, message]
              properties:
                subject: { type: string }
                message: { type: string }
          multipart/form-data:
            schema:
              allOf:
                - type: object
                  required: [subject]
                  properties:
                    subject: { type: string }
                    message: { type: string }
                - $ref: "#/components/schemas/TicketAttachmentUpload"
      responses:
        "201":
          description: The created ticket and its first message.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ticket: { $ref: "#/components/schemas/Ticket" }
                  first_message: { $ref: "#/components/schemas/TicketMessage" }
        "400":
          description: |
            Invalid body, a missing/oversized subject or message, or a rejected
            attachment — wrong type, over its size cap, more than eight files,
            or a file whose contents do not match its extension.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                badType:
                  value: { error: "only JPEG, PNG, GIF, WebP images or .txt/.log/.json files are allowed (images up to 3 MB, text up to 1 MB)" }
        "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
      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
        files. See `TicketAttachmentUpload` for what may be attached.

        With `multipart/form-data`, `message` may be empty as long as at least
        one file is attached; with JSON it is required. Replies are only
        accepted while the ticket is open.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string }
          multipart/form-data:
            schema:
              allOf:
                - type: object
                  properties:
                    message: { type: string }
                - $ref: "#/components/schemas/TicketAttachmentUpload"
      responses:
        "200":
          description: The created message.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TicketMessage" }
        "400":
          description: |
            Invalid id, invalid body, an empty/oversized message, a closed
            ticket, or a rejected attachment — wrong type, over its size cap,
            more than eight files, or contents that do not match the extension.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                closed:
                  value: { error: "ticket is closed" }
                badType:
                  value: { error: "only JPEG, PNG, GIF, WebP images or .txt/.log/.json files are allowed (images up to 3 MB, text up to 1 MB)" }
        "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" }

  # ---------------------------------------------------------------------------
  # Notifications
  # ---------------------------------------------------------------------------

  /notifications:
    get:
      tags: [Notifications]
      operationId: listNotifications
      summary: List your notifications
      description: |
        Every event NSIN raised for you — domain lifecycle, uptime, SSL expiry,
        plan and wallet — newest first. Domain events reach the domain owner and
        any member subscribed to that category, so this returns your own copy.

        `total` and `unread_count` always describe the whole feed, not the
        page or the filter, so they can drive a tab label or a badge directly.
      parameters:
        - name: limit
          in: query
          description: Page size. Default 20, maximum 100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: before
          in: query
          description: |
            Return only notifications with a lower id — the cursor for fetching
            the next page. Use the last id of the previous page.
          schema: { type: integer }
        - name: unread
          in: query
          description: Set to `true` to return only notifications you have not seen.
          schema: { type: boolean }
        - name: domain
          in: query
          description: |
            Restrict the feed to one domain, by numeric domain id. Account-wide
            notifications (wallet, invoices, tickets) are excluded when set.
          schema: { type: integer }
      responses:
        "200":
          description: Notifications.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Notification" }
                  total: { type: integer, description: Notifications in the whole feed. }
                  unread_count: { type: integer, description: Unseen notifications in the whole feed. }
                  has_more: { type: boolean, description: Another page exists below this one. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /notifications/unread-count:
    get:
      tags: [Notifications]
      operationId: getNotificationUnreadCount
      summary: Count unseen notifications
      description: The number behind the panel's notification badge.
      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" }

  /notifications/read:
    post:
      tags: [Notifications]
      operationId: markNotificationsRead
      summary: Mark notifications as seen
      description: |
        Stamps the given notifications as seen, which is what removes them from
        the unread count. Send `ids` for specific notifications, or `all: true`
        for the whole feed. Notifications already seen keep their original
        timestamp, so replaying a request is harmless.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Provide `ids` or `all`.
              properties:
                ids:
                  type: array
                  description: Notification ids to mark seen. At most 100.
                  items: { type: integer }
                all:
                  type: boolean
                  description: Mark the whole feed seen. Ignores `ids`.
      responses:
        "200":
          description: The number of notifications updated, and the unread count that remains.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
                  unread_count: { type: integer }
        "400":
          description: Neither `ids` nor `all` was given, or more than 100 ids were sent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ReadOnlyKey" }
        "429": { $ref: "#/components/responses/RateLimited" }

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

  /proxy-ip/:
    get:
      tags: [Account]
      operationId: getProxyIp
      summary: Get the edge proxy IP
      description: |
        What to point DNS at for an externally-hosted zone. For managed
        domains NSIN sets this automatically when you mark a record proxied.

        Prefer `cname` for subdomains when it is present: a CNAME follows any
        future change of our addresses, an A record does not. The apex has to
        use the A addresses in `ips`. Per-record values (which honour address
        pins) are on each record's `edge_target` in the records listing.
      responses:
        "200":
          description: Proxy IP.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ip: { type: string, description: The first edge address (legacy single value). }
                  ips:
                    type: array
                    description: Every edge address a proxied record publishes.
                    items: { type: string }
                  cname:
                    type: string
                    description: Hostname to CNAME proxied subdomains at. Empty when not configured.
                    example: edge.nsin.ir
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # NSIN SSO (OAuth 2.1 / OpenID Connect provider)
  #
  # These endpoints are NOT reachable with an `nsin_` API key — a key that could
  # mint an SSO token would be a permanent login to every connected app. Each
  # one carries its own `security` block naming the credential it does take.
  #
  # Register a client with NSIN support to obtain a `client_id`. The flow is
  # authorization code + PKCE (S256 only, mandatory for every client, public and
  # confidential alike):
  #
  #   1. Send the browser to {panel}/oauth/authorize with client_id,
  #      redirect_uri, response_type=code, scope, state and code_challenge.
  #   2. NSIN redirects back to your redirect_uri with ?code=…&state=…&iss=…
  #   3. Exchange the code at POST /oauth/token with your code_verifier.
  #   4. Call your own API with the access token; refresh 60 s before it expires.
  # ---------------------------------------------------------------------------

  /.well-known/openid-configuration:
    get:
      tags: [SSO]
      operationId: ssoDiscovery
      summary: OpenID Connect discovery document
      security: []
      description: |
        Everything an OIDC client needs to configure itself: the issuer, the
        endpoint URLs, and the response types, grant types, scopes and signing
        algorithms this provider supports.

        Note that `authorization_endpoint` points at the NSIN **panel**
        (`https://panel.nsin.ir/oauth/authorize`), not at this API — that step
        is a page the user sees, not a request your server makes.

        Cacheable for an hour.
      responses:
        "200":
          description: Discovery document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  issuer: { type: string, example: "https://api.nsin.ir" }
                  authorization_endpoint: { type: string, example: "https://panel.nsin.ir/oauth/authorize" }
                  token_endpoint: { type: string, example: "https://api.nsin.ir/oauth/token" }
                  userinfo_endpoint: { type: string, example: "https://api.nsin.ir/oauth/userinfo" }
                  jwks_uri: { type: string, example: "https://api.nsin.ir/.well-known/jwks.json" }
                  revocation_endpoint: { type: string, example: "https://api.nsin.ir/oauth/revoke" }
                  response_types_supported: { type: array, items: { type: string } }
                  response_modes_supported: { type: array, items: { type: string } }
                  grant_types_supported: { type: array, items: { type: string } }
                  subject_types_supported: { type: array, items: { type: string } }
                  code_challenge_methods_supported: { type: array, items: { type: string } }
                  token_endpoint_auth_methods_supported: { type: array, items: { type: string } }
                  scopes_supported: { type: array, items: { type: string } }
                  id_token_signing_alg_values_supported: { type: array, items: { type: string } }
                  claims_supported: { type: array, items: { type: string } }
                  authorization_response_iss_parameter_supported: { type: boolean }

  /.well-known/jwks.json:
    get:
      tags: [SSO]
      operationId: ssoJwks
      summary: Token signing keys (JWKS)
      security: []
      description: |
        The RSA public keys that verify our access and ID tokens, as a JWK Set.
        Verify offline against these — never by calling back to NSIN on every
        request. Cache by `kid` and refetch once when you meet a `kid` you do
        not know. Cacheable for an hour.
      responses:
        "200":
          description: JWK Set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        kty: { type: string, example: RSA }
                        use: { type: string, example: sig }
                        alg: { type: string, example: RS256 }
                        kid: { type: string }
                        n: { type: string, description: Modulus, base64url. }
                        e: { type: string, description: Exponent, base64url. }

  /oauth/client-info:
    get:
      tags: [SSO]
      operationId: ssoClientInfo
      summary: Look up a client before redirecting
      security: []
      description: |
        Confirms that a `client_id` exists and is active, and — when
        `redirect_uri` is given — that the URI is registered for it. The panel
        calls this before it redirects, so a mistyped client or redirect is
        reported on screen instead of being bounced to an unverified URL.

        Nothing secret is returned.
      parameters:
        - name: client_id
          in: query
          required: true
          schema: { type: string }
          example: nc_9f3c1d2e4b5a6789
        - name: redirect_uri
          in: query
          required: false
          description: When present, it must be one of the client's registered URIs, matched exactly.
          schema: { type: string }
      responses:
        "200":
          description: The client's public description.
          content:
            application/json:
              schema:
                type: object
                properties:
                  name: { type: string }
                  description: { type: string }
                  first_party: { type: boolean, description: NSIN's own app — consent is implicit. }
                  public: { type: boolean, description: PKCE-only client with no secret. }
        "400":
          description: The redirect_uri is not registered for this client.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "404":
          description: Unknown or inactive client.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /oauth/authorize:
    post:
      tags: [SSO]
      operationId: ssoAuthorize
      summary: Approve an authorization request
      security:
        - panelAuth: []
      description: |
        The server side of the panel's `/oauth/authorize` page. It is called by
        the NSIN panel with the signed-in user's session, never by a relying
        party: your application's part of this step is the browser redirect to
        `{panel}/oauth/authorize?…`, and the redirect back that follows.

        Documented here because it defines the parameters your redirect must
        carry. `state` is required. PKCE is mandatory for every client:
        `code_challenge_method` must be `S256` and `code_challenge` must be
        `BASE64URL(SHA256(code_verifier))` with a verifier of 43–128 unreserved
        characters.

        A first-party client is approved silently. A third-party client is
        approved once per scope set: the first request answers
        `consent_required` with the client's name and the human descriptions of
        the scopes, and the panel repeats the request with `consent: true`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method]
              properties:
                client_id: { type: string }
                redirect_uri: { type: string, description: Must match one of the client's registered URIs exactly. }
                response_type: { type: string, enum: [code] }
                scope: { type: string, description: "Space-separated. Must include `openid`.", example: "openid profile email offline_access" }
                state: { type: string, description: Opaque value echoed back on the redirect. Required. }
                code_challenge: { type: string }
                code_challenge_method: { type: string, enum: [S256] }
                nonce: { type: string, description: Echoed in the ID token. }
                consent: { type: boolean, description: Set by the panel when the user approves a third-party client. }
      responses:
        "200":
          description: |
            Either the URL to send the browser to, or a request for consent.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      redirect_to:
                        type: string
                        description: The registered redirect_uri with `code`, `state` and `iss` appended.
                        example: "https://app.example/auth/callback?code=nac_…&state=xyz&iss=https%3A%2F%2Fapi.nsin.ir"
                  - type: object
                    properties:
                      consent_required: { type: boolean, enum: [true] }
                      client:
                        type: object
                        properties:
                          name: { type: string }
                          description: { type: string }
                      scopes:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            description: { type: string }
        "400":
          description: |
            `invalid_client`, `invalid_redirect_uri`, `unsupported_response_type`,
            `invalid_request` (missing or malformed PKCE / state) or
            `invalid_scope`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "401":
          description: No panel session, or a legacy session token with no session id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: The account is not active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /oauth/token:
    post:
      tags: [SSO]
      operationId: ssoToken
      summary: Exchange a code, or refresh a token
      security: []
      description: |
        Accepts `application/x-www-form-urlencoded` (the OAuth default) and
        JSON. A confidential client authenticates with `client_secret_post` or
        HTTP Basic; a public client sends no secret and is protected by PKCE
        alone.

        **`grant_type=authorization_code`** — send `code`, `redirect_uri`,
        `client_id` and `code_verifier`. The code is single-use and lives five
        minutes. A code that never existed, has expired or has already been
        redeemed all answer the same `invalid_grant`.

        **`grant_type=refresh_token`** — send `refresh_token` and `client_id`.
        Refresh tokens rotate: the response carries a new one and the presented
        token is burnt. Presenting a burnt token is treated as a leak and
        revokes every token of that login. `scope` may be sent to narrow the new
        token; it can never widen.

        A `refresh_token` is only issued when the `offline_access` scope was
        granted. Rate limited to 60 requests per minute per IP and 600 per
        minute per `client_id`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
          application/json:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
      responses:
        "200":
          description: A fresh token set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string, description: RS256 JWT. Verify it against the JWKS. }
                  token_type: { type: string, enum: [Bearer] }
                  expires_in: { type: integer, description: Access token lifetime in seconds. }
                  refresh_token: { type: string, description: Present only when `offline_access` was granted. }
                  id_token: { type: string, description: RS256 JWT carrying the identity claims the scopes allow. }
                  scope: { type: string, description: The scopes actually granted, space-separated. }
        "400":
          description: |
            `invalid_request`, `unsupported_grant_type`, `invalid_grant`
            (unknown, expired, already-used or mismatched code or refresh
            token — deliberately indistinguishable), `invalid_scope`, or
            `unauthorized_client` for a client that has been switched off.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "401":
          description: |
            `invalid_client` — the client id is unknown or the secret is wrong.
            Carries `WWW-Authenticate: Basic` when Basic auth was attempted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "429":
          description: Too many token requests.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                slowDown:
                  value: { error: "slow_down" }

  /oauth/revoke:
    post:
      tags: [SSO]
      operationId: ssoRevoke
      summary: Revoke a refresh token
      security: []
      description: |
        Revokes a refresh token and every token rotated from the same login —
        this is what a relying party calls on sign-out. Access tokens are short
        lived and are not tracked; they stop working when they expire, or
        sooner if the underlying NSIN session is revoked.

        Per RFC 7009 the answer is always `200`, whether or not the token
        existed: an error would turn this endpoint into a way to test tokens.
        A confidential client must send its secret, or the request is a silent
        no-op.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthRevokeRequest" }
          application/json:
            schema: { $ref: "#/components/schemas/OAuthRevokeRequest" }
      responses:
        "200":
          description: Always, regardless of whether anything was revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }

  /oauth/userinfo:
    get:
      tags: [SSO]
      operationId: ssoUserinfo
      summary: The signed-in user's claims
      security:
        - ssoAuth: []
      description: |
        The OIDC UserInfo endpoint. Returns the claims the token's scopes allow
        — nothing more, and nothing at all beyond `sub` without `profile`,
        `email` or `phone`.

        The token is verified here directly: signature, expiry, the client still
        being active, the account still being active, and the underlying NSIN
        session still being live. Signing out of NSIN therefore takes this
        endpoint away from a relying party within the access-token lifetime.
      responses:
        "200":
          description: Claims.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sub: { type: string, description: The NSIN user id, as a decimal string. }
                  updated_at: { type: integer, description: Unix seconds of the last profile change. }
                  name: { type: string, description: Scope `profile`. }
                  email: { type: string, description: Scope `email`. }
                  email_verified: { type: boolean, description: Scope `email`. }
                  phone_number: { type: string, description: Scope `phone`. }
                  phone_number_verified: { type: boolean, description: Scope `phone`. }
                  admin: { type: boolean, description: First-party clients only. }
                  national_code: { type: string, description: First-party clients with scope `nsin:identity` only. }
                  birth_date: { type: string, description: First-party clients with scope `nsin:identity` only. }
        "401":
          description: |
            `invalid_token` — missing, malformed, expired or revoked. Carries a
            `WWW-Authenticate: Bearer` header.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /oauth/grants:
    get:
      tags: [SSO]
      operationId: ssoListGrants
      summary: Connected apps
      security:
        - panelAuth: []
      description: |
        The third-party applications this user has approved, as shown in the
        panel under *Connected apps*. First-party applications are not listed:
        their access is implicit in having an NSIN account.

        Panel session only — an SSO token cannot read or change this list, so
        that no connected app can see or revoke another.
      responses:
        "200":
          description: Approved applications.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    client_id: { type: string }
                    name: { type: string }
                    scope: { type: string, description: Space-separated scopes the user approved. }
                    created_at: { type: string, format: date-time }
                    last_used_at: { type: string, format: date-time, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /oauth/grants/{client_id}:
    delete:
      tags: [SSO]
      operationId: ssoRevokeGrant
      summary: Disconnect an app
      security:
        - panelAuth: []
      description: |
        Withdraws the user's approval of one application and revokes every
        refresh token it holds for them, so it is signed out rather than merely
        un-listed. It can ask for consent again on the next sign-in.
      parameters:
        - name: client_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The application is disconnected.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }


  # ---------------------------------------------------------------------------
  # Email Routing
  #
  # Custom addresses on a managed domain forwarded to verified destinations.
  # Enabling publishes three MX rows, an SPF record (created, or your existing
  # one with our include merged in) and a DKIM TXT into the zone, all locked
  # (`editable: false`, `managed_by: email_routing`) until routing is disabled
  # or unlocked for a migration. Destinations belong to an ACCOUNT and are
  # shared by every domain of that account; a rule on a shared domain picks
  # from the domain OWNER's destinations.
  #
  # Managed DNS only (`dns_mode: managed`), and the plan must include
  # `email_routing_enabled` (see the domain features endpoint).
  # ---------------------------------------------------------------------------

  /domains/{domain}/email-routing/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Email Routing]
      operationId: getEmailRouting
      summary: Email routing status
      description: |
        Everything the routing page shows: whether routing is on and its
        status, the published records with their last public-check verdict,
        what enabling would have to remove or merge (while unconfigured), the
        last 24 hours' counts, the plan's caps against current usage, the DKIM
        record and the platform's mail hosts. Requires `domain.view`.
      responses:
        "200":
          description: Routing status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/email-routing/enable:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Email Routing]
      operationId: enableEmailRouting
      summary: Enable email routing
      description: |
        Publishes the MX, SPF and DKIM records and turns routing on. Any MX
        records the domain already has must be removed (they would route mail
        elsewhere) — send `remove_existing_mx: true` to agree, otherwise the
        call answers `409` with `conflict: mx` and the rows. An existing SPF
        record is merged rather than duplicated (`merge_spf: true`); two SPF
        records answer `409` with `conflict: spf_multiple`. The removed MX rows
        are kept so a later disable can put them back.

        Refused while no mail host is ready (`503`, `code: hosts_not_ready`),
        on external DNS (`409`, `conflict: external_dns`) and on a plan
        without the feature (`403`). Idempotent: an enabled domain answers
        `200` unchanged. Requires `domain.settings`.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailRoutingEnableRequest" }
      responses:
        "200":
          description: Routing enabled; the status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/EmailRoutingPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409": { $ref: "#/components/responses/EmailRoutingConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/EmailRoutingDNSWriteFailed" }
        "503":
          description: |
            No mail host has checked in recently (`code: hosts_not_ready`), or
            the platform is missing its key-encryption key (`code: kek_unset`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingCodedError" }

  /domains/{domain}/email-routing/disable:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Email Routing]
      operationId: disableEmailRouting
      summary: Disable email routing
      description: |
        Removes the managed records (and our include term from a merged SPF),
        optionally re-creates the MX records enable removed, and turns routing
        off. Rules and destinations are kept for a later enable. Idempotent.
        Requires `domain.settings`.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailRoutingDisableRequest" }
      responses:
        "200":
          description: Routing disabled; the status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/EmailRoutingDNSWriteFailed" }

  /domains/{domain}/email-routing/unlock:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Email Routing]
      operationId: unlockEmailRoutingRecords
      summary: Unlock the managed records
      description: |
        Makes the MX/SPF/DKIM rows editable through the record endpoints so
        you can migrate away at your own pace. Routing keeps working while the
        records still exist; status becomes `unlocked`. Requires
        `domain.settings`.
      responses:
        "200":
          description: Records unlocked; the status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409": { $ref: "#/components/responses/EmailRoutingNotEnabled" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/email-routing/lock:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Email Routing]
      operationId: lockEmailRoutingRecords
      summary: Lock the managed records
      description: Reverses unlock. Requires `domain.settings`.
      responses:
        "200":
          description: Records locked; the status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409": { $ref: "#/components/responses/EmailRoutingNotEnabled" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/email-routing/dns/check:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    post:
      tags: [Email Routing]
      operationId: checkEmailRoutingDNS
      summary: Check the published records now
      description: |
        Queries the public nameserver for the domain's MX, SPF and DKIM and
        marks each managed record `ok`, `missing` or `extra` (a foreign MX or
        a second SPF). A domain whose records do not answer is `misconfigured`
        and its mail hosts stop accepting for it until the records are back.
        The same check runs every five minutes on its own. Once a minute per
        domain: sooner answers `429` with `retry_after_seconds`. Requires
        `domain.view`.
      responses:
        "200":
          description: Fresh verdict; the status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409": { $ref: "#/components/responses/EmailRoutingNotEnabled" }
        "429":
          description: The per-key rate limit, or a check less than a minute ago.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportScanThrottledError" }
        "502":
          description: The public lookup failed; nothing changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /domains/{domain}/email-routing/settings:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    put:
      tags: [Email Routing]
      operationId: updateEmailRoutingSettings
      summary: Update routing settings
      description: |
        Omitted fields are untouched. `subaddressing` lets `user+tag@` match
        the `user@` rule (an exact `user+tag@` rule still wins) and is what
        permits a `+` in a rule's local part. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailRoutingSettingsRequest" }
      responses:
        "200":
          description: Settings saved; the status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingStatus" }
        "400":
          description: Malformed 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" }

  /domains/{domain}/email-routing/catch-all:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Email Routing]
      operationId: getEmailCatchAll
      summary: Catch-all
      description: |
        What happens to mail for an address no rule matches. Requires
        `domain.view`.
      responses:
        "200":
          description: The catch-all.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailCatchAll" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Email Routing]
      operationId: updateEmailCatchAll
      summary: Update the catch-all
      description: |
        `destination_id` is required when `action` is `forward` and must be a
        VERIFIED destination on the domain owner's account. Requires
        `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailCatchAllRequest" }
      responses:
        "200":
          description: The catch-all as saved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailCatchAll" }
        "400":
          description: |
            Invalid action, a missing or unverified destination, or a
            destination on the domain itself (`code: loop`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingCodedError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/email-routing/rules/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Email Routing]
      operationId: listEmailRules
      summary: List email addresses
      description: |
        Every custom address on the domain, by local part. `active` says
        whether the address is live right now: enabled, routing on, and (for
        `forward`) its destination verified. Requires `domain.view`.
      responses:
        "200":
          description: Rule list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/EmailRule" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Email Routing]
      operationId: createEmailRule
      summary: Create an email address
      description: |
        `local_part` is the part before `@` — ASCII letters, digits and
        `!#$%&'*+/=?^_\`{|}~.-`, at most 64 characters, lowercased; a `+` only
        when the domain's `subaddressing` is on. `action` is `forward` (with a
        `destination_id` from the domain owner's account) or `drop`. A rule
        whose destination is not verified yet is stored and goes live on
        verification. Counts against the plan's `max_email_rules`. Requires
        `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailRuleRequest" }
      responses:
        "201":
          description: The created rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRule" }
        "400":
          description: Invalid local part, action or destination (`field` names it).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingCodedError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/EmailRoutingPlanLimited" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: "An address with this local part already exists (`conflict: local_part`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingConflictError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/email-routing/rules/{ruleId}:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/EmailRuleId"
    put:
      tags: [Email Routing]
      operationId: updateEmailRule
      summary: Update an email address
      description: Omitted fields are untouched. Same rules as create. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailRuleRequest" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRule" }
        "400":
          description: Invalid local part, action or destination (`field` names it).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingCodedError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/EmailRuleNotFound" }
        "409":
          description: "An address with this local part already exists (`conflict: local_part`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingConflictError" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Email Routing]
      operationId: deleteEmailRule
      summary: Delete an email address
      description: Requires `records.edit`.
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/EmailRuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/email-routing/rules/{ruleId}/toggle:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/EmailRuleId"
    post:
      tags: [Email Routing]
      operationId: toggleEmailRule
      summary: Enable or disable an email address
      description: Flips `enabled`. Requires `records.edit`.
      responses:
        "200":
          description: The rule after the flip.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRule" }
        "400":
          description: The rule forwards but has no destination; set one first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/EmailRuleNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domains/{domain}/email-routing/summary:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/EmailRange"
      - name: from
        in: query
        description: Start of a `custom` range, RFC 3339.
        schema: { type: string, format: date-time }
      - name: to
        in: query
        description: End of a `custom` range, RFC 3339.
        schema: { type: string, format: date-time }
    get:
      tags: [Email Routing]
      operationId: getEmailSummary
      summary: Message counts over time
      description: |
        Received / forwarded / dropped / rejected / deferred, as totals and as
        a bucketed series. `received` is every message except discarded
        bounces. Requires `domain.view`.
      responses:
        "200":
          description: Totals and series.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailSummary" }
        "400":
          description: Bad range or custom bounds.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/EmailActivityUnavailable" }

  /domains/{domain}/email-routing/activity:
    parameters:
      - $ref: "#/components/parameters/DomainName"
      - $ref: "#/components/parameters/EmailRange"
      - name: from
        in: query
        description: Start of a `custom` range, RFC 3339.
        schema: { type: string, format: date-time }
      - name: to
        in: query
        description: End of a `custom` range, RFC 3339.
        schema: { type: string, format: date-time }
      - name: status
        in: query
        description: Narrow to one status.
        schema:
          type: string
          enum: [forwarded, dropped, rejected, deferred, bounce_dropped]
      - name: q
        in: query
        description: Case-insensitive match against sender, recipient, subject and Message-ID.
        schema: { type: string }
      - name: page
        in: query
        schema: { type: integer, default: 1, minimum: 1 }
      - name: per_page
        in: query
        schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
    get:
      tags: [Email Routing]
      operationId: getEmailActivity
      summary: Activity log
      description: |
        One row per message, newest first: envelope addresses, the
        authentication verdicts (SPF, DKIM, DMARC, ARC), what happened to it
        and why. `subject` is present only while subject logging is on for
        the platform. Kept 30 days. Requires `domain.view`.
      responses:
        "200":
          description: A page of events.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailActivity" }
        "400":
          description: Bad range or custom bounds.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/EmailActivityUnavailable" }

  /domains/{domain}/email-routing/destinations/:
    parameters:
      - $ref: "#/components/parameters/DomainName"
    get:
      tags: [Email Routing]
      operationId: listDomainEmailDestinations
      summary: List the domain owner's destinations
      description: |
        The destinations a rule on this domain may forward to — the domain
        OWNER's account's, whoever is asking. Requires `domain.view`.
      responses:
        "200":
          description: Destination list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/EmailDestination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Email Routing]
      operationId: createDomainEmailDestination
      summary: Add a destination to the domain owner's account
      description: |
        Same as the account-level create, but the destination lands on the
        domain OWNER's account and the verification mail names this domain.
        Only the owner and admin-role members may; editors choose from the
        list instead. Requires `records.edit`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailDestinationRequest" }
      responses:
        "201":
          description: Destination created; the verification mail is on its way.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailDestinationCreated" }
        "400":
          description: "Invalid address, a domain with no mail server (`code: no_mx`), or a loop (`code: loop`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingCodedError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Editor role, or the account is at its destination cap.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/DomainNotFound" }
        "409":
          description: "The account already has this destination (`conflict: destination`, the row under `destination`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingConflictError" }
        "429": { $ref: "#/components/responses/EmailVerifyThrottled" }

  /email-routing/destinations/:
    get:
      tags: [Email Routing]
      operationId: listEmailDestinations
      summary: List my destinations
      description: |
        Every destination on your own account, verified or pending, with how
        many rules use each.
      responses:
        "200":
          description: Destination list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/EmailDestination" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Email Routing]
      operationId: createEmailDestination
      summary: Add a destination
      description: |
        Adds an address to your account and emails it a verification link.
        Rules may point at it immediately but only fire once it is verified.
        Refused for an address on a domain that itself uses NSIN email routing
        (`code: loop`) or whose domain has no mail server (`code: no_mx`).
        Sends are paced: one per destination per minute, ten per account and
        forty per client address per hour.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailDestinationRequest" }
      responses:
        "201":
          description: Destination created; the verification mail is on its way.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailDestinationCreated" }
        "400":
          description: Invalid address, no mail server, or a loop (`code`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingCodedError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The account is at its destination cap (`max_destinations`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: "The account already has this destination (`conflict: destination`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailRoutingConflictError" }
        "429": { $ref: "#/components/responses/EmailVerifyThrottled" }

  /email-routing/destinations/{destinationId}/resend:
    parameters:
      - $ref: "#/components/parameters/EmailDestinationId"
    post:
      tags: [Email Routing]
      operationId: resendEmailDestinationVerification
      summary: Resend the verification mail
      description: |
        Issues a fresh link for an unverified destination. Subject to the same
        cooldown and hourly caps as create.
      responses:
        "200":
          description: Sent; how long until the next resend is allowed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  resend_cooldown_seconds: { type: integer }
        "400":
          description: The destination is already verified.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/EmailDestinationNotFound" }
        "429": { $ref: "#/components/responses/EmailVerifyThrottled" }

  /email-routing/destinations/{destinationId}:
    parameters:
      - $ref: "#/components/parameters/EmailDestinationId"
    delete:
      tags: [Email Routing]
      operationId: deleteEmailDestination
      summary: Delete a destination
      description: |
        Removes the destination and DISABLES every rule and catch-all that
        used it, on every domain of the account. The mail hosts stop
        forwarding there within a minute.
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/EmailDestinationNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /email-routing/destinations/verify:
    post:
      tags: [Email Routing]
      operationId: verifyEmailDestination
      summary: Verify a destination from its emailed link
      description: |
        Consumes the token from the verification mail. No authentication —
        the person clicking owns the destination mailbox and need not be an
        NSIN user; the single-use 256-bit token is the proof. Once verified,
        rules pointing at the destination go live within a minute.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmailVerifyRequest" }
      responses:
        "200":
          description: Verified.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmailVerifyResult" }
        "400":
          description: Malformed token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "410":
          description: The link expired or was already used.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

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.
    ssoAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        `Authorization: Bearer <access token>` where the token is an RS256 JWT
        issued by NSIN SSO (`POST /oauth/token`) — **not** an API key. Used by
        the SSO endpoints of this reference, and by any NSIN endpoint when the
        token carries the `nsin:full` scope.
    panelAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        The session token the NSIN panel holds after sign-in. Only the panel
        itself uses the endpoints marked with this scheme; they are documented
        so that an SSO integration can see what the browser step does.

  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 }

    GatewayId:
      name: gatewayId
      in: path
      required: true
      description: Numeric id of the gateway, from the gateway list.
      schema: { type: integer }

    EmailRuleId:
      name: ruleId
      in: path
      required: true
      description: Numeric id of the email address rule, from the rule list.
      schema: { type: integer }

    EmailDestinationId:
      name: destinationId
      in: path
      required: true
      description: Numeric id of the email destination, from the destination list.
      schema: { type: integer }

    EmailRange:
      name: range
      in: query
      description: |
        Time window, ending now. `30m` buckets by minute, `24h` by hour, `7d`
        and `30d` by day. `custom` takes RFC 3339 `from` and `to` (at most 31
        days apart) and picks the bucket from the span.
      schema:
        type: string
        enum: ["30m", "24h", "7d", "30d", "custom"]
        default: "24h"

    ImportSessionId:
      name: sessionId
      in: path
      required: true
      description: |
        Numeric id of the record import session. Sessions are resolved within
        the domain, so an id from another domain reads as not found.
      schema: { type: integer }

  responses:

    Unauthorized:
      description: |
        Missing, malformed, revoked or expired API key — or the owning account is
        inactive.
      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, or the domain's plan does not include the feature.
      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 your role on it does not permit this operation. The
        rules endpoints deliberately answer `404` rather than `403` for an
        insufficient role, so they never confirm that a domain exists to someone
        who cannot use it.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notFound:
              value: { error: "not found" }

    RuleNotFound:
      description: |
        The domain or the rule does not exist, the rule belongs to another
        domain or another rule type, or your role does not permit this
        operation.
      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, 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" }

    UptimeMonitorInvalid:
      description: |
        The `domain` query parameter is missing, the body is malformed, a scope
        entry is too long, a regex entry does not compile, the monitor narrows
        nothing, or the domain is already at its monitor cap.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notNarrowed:
              value: { error: "a monitor must narrow something: set a subdomain scope or a path scope" }
            capReached:
              value: { error: "a domain may have at most 10 uptime monitors" }

    UptimeMonitorNotFound:
      description: |
        No such monitor on this domain. A monitor id belonging to another domain
        is reported the same way, so an id can never be used to probe another
        tenant.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notFound:
              value: { error: "monitor not found" }

    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 analytics backend is temporarily unreachable. Retry; no data is
        lost.
      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" }

    ImportSessionNotFound:
      description: |
        No such domain or import session, the session belongs to another domain,
        or the domain is not visible to this account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notFound:
              value: { error: "import session not found" }

    ImportScanThrottled:
      description: |
        A scan for this domain ran less than five minutes ago. One scan is up to
        roughly 1100 outbound DNS queries, so it is throttled per domain — this
        is separate from, and additional to, the per-key rate limit. Wait
        `retry_after_seconds` and retry.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ImportScanThrottledError" }
          examples:
            throttled:
              value:
                error: "a scan for this domain ran a moment ago; please wait before scanning again"
                retry_after_seconds: 173

    EmailRoutingPlanLimited:
      description: |
        The key is read-only, your role lacks the permission, the domain has no
        active plan (`402`-class message with `upgrade_url`), the plan does not
        include Email Routing, or the plan's `max_email_rules` is reached —
        the body then carries `max_rules` and `upgrade_url`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EmailRoutingPlanLimitedError" }
          examples:
            atCap:
              value:
                error: "You have reached the maximum number of email addresses for this domain's plan. Upgrade the plan for this domain to add more."
                max_rules: 50
                upgrade_url: "/plans"

    EmailRoutingConflict:
      description: |
        Something in the zone has to change first. `conflict` says what:
        `mx` (existing MX records, listed under `records` — re-send with
        `remove_existing_mx: true`), `spf` (an existing SPF record, with the
        `merged` preview — re-send with `merge_spf: true`), `spf_multiple`
        (two SPF records; keep one), `external_dns` (the domain does not use
        NSIN DNS) or `domain_status` (the domain is disabled).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EmailRoutingConflictError" }
          examples:
            existingMX:
              value:
                error: "this domain already has MX records; enabling email routing replaces them"
                conflict: mx
                records:
                  - { id: 12, name: "@", content: "mail.example.ir.", priority: 10 }

    EmailRoutingNotEnabled:
      description: "Email routing is not enabled on this domain (`conflict: not_enabled`)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EmailRoutingConflictError" }

    EmailRoutingDNSWriteFailed:
      description: |
        The nameservers could not be updated; nothing was changed. Retry in a
        moment.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    EmailRuleNotFound:
      description: No such domain, or no such rule on it.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    EmailDestinationNotFound:
      description: No such destination on your account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    EmailActivityUnavailable:
      description: The activity log store is temporarily unreachable. Retry; no data is lost.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            unavailable:
              value: { error: "activity log temporarily unavailable" }

    EmailVerifyThrottled:
      description: |
        The per-key rate limit, a verification mail to this destination less
        than a minute ago (`cooldownSecondsRemaining`), or the account's or
        your address's hourly send cap.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  cooldownSecondsRemaining: { type: integer }

    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:

    # -------------------------------------------------------------------------
    # Email Routing
    # -------------------------------------------------------------------------

    EmailRoutingCodedError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            code:
              type: string
              description: Machine-readable reason (`hosts_not_ready`, `kek_unset`, `loop`, `no_mx`, `unverified`).
            field:
              type: string
              description: The offending body field, when one applies.

    EmailRoutingConflictError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            conflict:
              type: string
              description: What conflicts (`mx`, `spf`, `spf_multiple`, `external_dns`, `domain_status`, `not_enabled`, `local_part`, `destination`).
            records:
              type: array
              description: For `mx` / `spf_multiple` — the rows in the way.
              items: { $ref: "#/components/schemas/EmailConflictRecord" }
            record_id: { type: integer, description: For `spf` — the existing SPF row. }
            content: { type: string, description: For `spf` — its current value. }
            merged: { type: string, description: For `spf` — what it becomes with our include merged in. }
            destination:
              $ref: "#/components/schemas/EmailDestination"
              description: For `destination` — the existing row.

    EmailRoutingPlanLimitedError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            max_rules: { type: integer, nullable: true }
            upgrade_url: { type: string }

    EmailConflictRecord:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        content: { type: string }
        priority: { type: integer }

    EmailRoutingEnableRequest:
      type: object
      properties:
        remove_existing_mx:
          type: boolean
          description: Agree to remove the domain's existing MX records (they are snapshotted for disable).
        merge_spf:
          type: boolean
          description: Agree to merge our include term into the domain's existing SPF record.

    EmailRoutingDisableRequest:
      type: object
      properties:
        restore_previous_mx:
          type: boolean
          description: Re-create the MX records enable removed.

    EmailRoutingSettingsRequest:
      type: object
      properties:
        subaddressing:
          type: boolean
          description: Match `user+tag@` against the `user@` rule.

    EmailPlatform:
      type: object
      properties:
        mx_hosts:
          type: array
          items:
            type: object
            properties:
              host: { type: string }
              priority: { type: integer }
        spf_include: { type: string }
        forward_domain: { type: string }

    EmailDNSRecord:
      type: object
      properties:
        type: { type: string, enum: [MX, TXT] }
        name: { type: string, description: Relative to the domain; `@` is the apex. }
        content: { type: string }
        priority: { type: integer }
        ttl: { type: integer }
        state:
          type: string
          enum: [ok, missing, extra, unchecked]
          description: "`extra` is a foreign MX or a second SPF seen publicly."
        record_id: { type: integer, description: "The managed record's id, when the row is ours." }

    EmailRoutingStatus:
      type: object
      properties:
        enabled: { type: boolean }
        status:
          type: string
          enum: [unconfigured, ready, misconfigured, unlocked, suspended]
        hosts_ready:
          type: boolean
          description: At least one mail host has checked in recently. Enable is refused while false.
        locked: { type: boolean, description: The managed records are not editable. }
        subaddressing: { type: boolean }
        suspended_reason: { type: string }
        catch_all: { $ref: "#/components/schemas/EmailCatchAll" }
        dns:
          type: object
          properties:
            checked_at: { type: string, format: date-time, nullable: true }
            ok: { type: boolean }
            records:
              type: array
              items: { $ref: "#/components/schemas/EmailDNSRecord" }
            problems:
              type: array
              items: { type: string }
        conflicts:
          type: object
          nullable: true
          description: Only while unconfigured — what enable would have to remove or merge.
          properties:
            mx:
              type: array
              items: { $ref: "#/components/schemas/EmailConflictRecord" }
            spf:
              type: object
              nullable: true
              properties:
                record_id: { type: integer }
                content: { type: string }
                merged: { type: string }
            spf_multiple: { type: boolean }
        previous_mx_available:
          type: boolean
          description: Enable removed MX records that disable can restore.
        counts_24h: { $ref: "#/components/schemas/EmailCounts" }
        limits:
          type: object
          properties:
            max_rules: { type: integer, nullable: true }
            rules_used: { type: integer }
            max_forwards_per_day: { type: integer, nullable: true }
            forwards_today: { type: integer }
        dkim:
          type: object
          description: >-
            Always present. Before Email Routing is enabled the selector and
            name are already known (they come from platform settings) and `txt`
            is empty, because the key is generated at enable time.
          properties:
            selector: { type: string }
            name: { type: string, description: The TXT name relative to the domain. }
            txt: { type: string }
        platform: { $ref: "#/components/schemas/EmailPlatform" }

    EmailCounts:
      type: object
      properties:
        received: { type: integer, description: Every message except discarded bounces. }
        forwarded: { type: integer }
        dropped: { type: integer }
        rejected: { type: integer }
        deferred: { type: integer }

    EmailCatchAll:
      type: object
      properties:
        enabled: { type: boolean }
        action: { type: string, enum: [forward, drop] }
        destination_id: { type: integer, nullable: true }
        destination_email: { type: string, nullable: true }
        destination_verified: { type: boolean }

    EmailCatchAllRequest:
      type: object
      properties:
        enabled: { type: boolean }
        action: { type: string, enum: [forward, drop] }
        destination_id: { type: integer, description: Required when `action` is `forward`; a verified destination on the owner's account. }

    EmailRule:
      type: object
      properties:
        id: { type: integer }
        local_part: { type: string }
        address: { type: string, description: "The full address, `local_part@domain`." }
        action: { type: string, enum: [forward, drop] }
        destination_id: { type: integer, nullable: true }
        destination_email: { type: string, nullable: true }
        destination_verified: { type: boolean }
        enabled: { type: boolean, description: Your switch. }
        active:
          type: boolean
          description: |
            Whether the address is live right now — enabled, routing on for the
            domain, and (for `forward`) the destination verified.
        name: { type: string, description: Optional label. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    EmailRuleRequest:
      type: object
      properties:
        local_part: { type: string }
        action: { type: string, enum: [forward, drop] }
        destination_id: { type: integer, description: Required for `forward`; from the domain owner's account. }
        name: { type: string, maxLength: 256 }
        enabled: { type: boolean, description: Update only; create always starts enabled. }

    EmailSummary:
      type: object
      properties:
        range: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        bucket: { type: string, enum: [minute, hour, day] }
        totals: { $ref: "#/components/schemas/EmailCounts" }
        series:
          type: array
          items:
            allOf:
              - type: object
                properties:
                  t: { type: string, format: date-time }
              - $ref: "#/components/schemas/EmailCounts"

    EmailEvent:
      type: object
      description: One recipient transaction on a mail host.
      properties:
        event_time: { type: string, format: date-time }
        node: { type: string, description: The mail host. }
        session_id: { type: string }
        txn_id: { type: string }
        domain_id: { type: integer }
        domain: { type: string }
        rule_id: { type: integer, description: "The rule that matched, 0 for none / catch-all." }
        message_id: { type: string }
        from_addr: { type: string }
        from_domain: { type: string }
        to_addr: { type: string }
        destination: { type: string, description: Where it was forwarded. }
        subject: { type: string, description: Truncated to 128; absent while subject logging is off. }
        status: { type: string, enum: [forwarded, dropped, rejected, deferred, bounce_dropped] }
        action: { type: string, enum: [forward, drop, catch_all, srs_bounce, none] }
        reject_stage: { type: string, description: "`connect | rbl | helo | mailfrom | rcpt | data | auth | loop | upstream | quota`, or empty." }
        spf: { type: string }
        dkim: { type: string }
        dmarc: { type: string }
        dmarc_policy: { type: string }
        arc: { type: string }
        error_code: { type: string, description: The SMTP code returned to the sender. }
        error_detail: { type: string, description: "The receiving server's text, when it refused." }
        client_ip: { type: string }
        client_helo: { type: string }
        client_ptr: { type: string }
        rbl_zone: { type: string }
        size: { type: integer }
        spam_score: { type: number }
        is_spam: { type: integer }
        tls_in: { type: string }
        tls_out: { type: string }
        tls_out_verified: { type: integer }
        upstream_mx: { type: string }
        upstream_ip: { type: string }
        egress_ip: { type: string }
        duration_ms: { type: integer }
        upstream_ms: { type: integer }
        attempts: { type: integer }

    EmailActivity:
      type: object
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/EmailEvent" }
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }

    EmailDestination:
      type: object
      properties:
        id: { type: integer }
        email: { type: string }
        verified: { type: boolean }
        verified_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        last_sent_at: { type: string, format: date-time, nullable: true, description: When the last verification mail went out. }
        resend_cooldown_seconds: { type: integer, description: Seconds until another verification mail may be sent; 0 when allowed now. }
        in_use: { type: integer, description: Rules and catch-alls pointing at it. }
        last_failure: { type: string, description: "The receiving server's last refusal, when forwards to it are bouncing." }
        last_failure_at: { type: string, format: date-time, nullable: true }

    EmailDestinationRequest:
      type: object
      required: [email]
      properties:
        email: { type: string, description: "A bare ASCII address, at most 254 characters." }

    EmailDestinationCreated:
      type: object
      properties:
        destination: { $ref: "#/components/schemas/EmailDestination" }
        resend_cooldown_seconds: { type: integer }

    EmailVerifyRequest:
      type: object
      required: [token]
      properties:
        token: { type: string, description: The 64-character token from the emailed link. }

    EmailVerifyResult:
      type: object
      properties:
        email: { type: string }
        verified: { type: boolean }

    Error:
      type: object
      description: The single error shape used by every endpoint.
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.
      examples:
        - { error: "read-only API key" }

    OAuthError:
      type: object
      description: |
        The RFC 6749 error shape, used by the NSIN SSO endpoints only. It is
        deliberately different from `Error`: OAuth clients match on the machine
        `error` code, and only show `error_description` to a developer.
      required: [error]
      properties:
        error:
          type: string
          description: The machine-readable code.
          example: invalid_grant
        error_description:
          type: string
          description: A human-readable explanation. Never match on this.
      examples:
        - { error: "invalid_grant", error_description: "authorization code is invalid or expired" }

    OAuthTokenRequest:
      type: object
      description: |
        The union of both grant types. Send it form-encoded or as JSON.
      required: [grant_type, client_id]
      properties:
        grant_type:
          type: string
          enum: [authorization_code, refresh_token]
        client_id: { type: string }
        client_secret:
          type: string
          description: |
            Confidential clients only, and only when not using HTTP Basic. A
            public client never sends one.
        code:
          type: string
          description: "`authorization_code` only — the value from the redirect."
        redirect_uri:
          type: string
          description: "`authorization_code` only — must equal the one the code was issued for."
        code_verifier:
          type: string
          description: |
            `authorization_code` only. The PKCE verifier, 43–128 characters from
            `[A-Za-z0-9._~-]`, whose SHA-256 produced the `code_challenge`.
        refresh_token:
          type: string
          description: "`refresh_token` only."
        scope:
          type: string
          description: |
            `refresh_token` only, and optional: narrows the new token. It can
            never widen — asking for more than was granted is `invalid_scope`.

    OAuthRevokeRequest:
      type: object
      required: [token, client_id]
      properties:
        token:
          type: string
          description: The refresh token to revoke.
        client_id: { type: string }
        client_secret:
          type: string
          description: Required for confidential clients; without it the call is a silent no-op.

    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 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.
        hsts_enabled:
          type: boolean
          description: |
            Send `Strict-Transport-Security`, telling browsers to use HTTPS only
            for this domain. Off by default.

            **This one cannot be undone.** A browser that has seen the header
            refuses plain HTTP for the whole `hsts_max_age_sec` even after the
            header stops being sent; switching it off only stops new visitors
            from being pinned. Enable it only when every path on the domain
            works over HTTPS. The edge's value replaces an origin's own
            `Strict-Transport-Security` header (a browser reads only the first
            one it receives).
        hsts_max_age_sec:
          type: integer
          enum: [0, 300, 3600, 86400, 604800, 2592000, 31536000]
          description: |
            How long browsers keep enforcing HTTPS, in seconds. `0` is
            "never set". Only the listed values are accepted, and this API
            raises it **one step at a time** — from 0 the only allowed next
            value is 300, then 3600, and so on. Lowering it is always allowed
            and is the way back out. The edge treats anything under 300 as off.
        hsts_include_subdomains:
          type: boolean
          description: |
            Add `includeSubDomains`, applying the rule to every subdomain,
            including ones created later. A subdomain not served over HTTPS
            becomes unreachable for visitors who have seen the header.
        hsts_preload:
          type: boolean
          description: |
            Add the `preload` directive, the prerequisite for submitting the
            domain at hstspreload.org. Requires `hsts_enabled`,
            `hsts_include_subdomains` and `hsts_max_age_sec` of 31536000.

            Effectively permanent: getting off the browser preload list means
            asking hstspreload.org to delist the domain and waiting for people
            to update their browsers. If a precondition is later removed, this
            flag is cleared with it.
        min_tls_version:
          type: string
          enum: ["1.2", "1.3"]
          description: |
            Lowest TLS version a visitor may connect with. `1.2` (default)
            accepts TLS 1.2 and 1.3; `1.3` refuses TLS 1.2 handshakes. Nothing
            below 1.2 is offered on any domain. It is enforced during the TLS
            handshake from the SNI, so a client below the minimum gets a
            connection error rather than an HTTP response. HTTP/3 is TLS 1.3 by
            definition and is unaffected.
        markdown_for_agents:
          type: boolean
          description: |
            Serve a Markdown rendering of eligible HTML pages to clients sending
            `Accept: text/markdown`. Requires an active plan.
        origin_protocol:
          type: string
          enum: [http1, http2, http3]
          description: |
            HTTP version the edge speaks to this domain's origins on the direct
            path. Default `http1`, and deliberately so: over HTTP/2 an upload is
            limited by the origin's per-stream window (64 KB on nginx), which
            caps each upload near 4 MB/s at a 16 ms edge-to-origin round trip.
            `http3` falls back to TCP per origin when the QUIC dial fails. The
            tunnel path from Iranian edges to foreign origins is always HTTP/1.1.
            Probe what your origin supports with `GET /domains/{domain}/origin-protocols`.
        default_static_cache:
          type: boolean
          description: |
            Cache static files (CSS, JavaScript, images, fonts, media, downloads)
            at the edge automatically, even with no cache rule — the way
            Cloudflare does for every proxied hostname. On by default. HTML is
            never cached by this; your own cache rules always take precedence,
            and any path you exclude in a rule is never cached by the default.
            Freshness follows your origin's `Cache-Control`; with none, 5 minutes.
        bot_cache_enabled:
          type: boolean
          description: |
            Bot cache: keep an edge copy of every HTML page for verified
            search-engine crawlers (Googlebot, Bingbot, Applebot, DuckDuckBot,
            YandexBot) and serve them from it, regardless of the origin's
            `Cache-Control`. Human visitors are unaffected. A crawler must pass
            the operator's own verification (published IP ranges / reverse DNS);
            a spoofed User-Agent never gets the copy. Requires a plan with
            `bot_cache_enabled`; the edge switches it off automatically while
            the plan lacks it.
        bot_cache_fresh_sec:
          type: integer
          description: |
            Bot cache: seconds a copy is served without asking the origin
            (300–86400, default 3600). Older copies are still served and
            refreshed in the background with a conditional request.
        bot_cache_max_age_sec:
          type: integer
          description: |
            Bot cache: seconds after which a copy is no longer served and is
            fetched again (300–86400, default 86400).
        bot_cache_kinds:
          type: array
          items: { type: string, enum: [googlebot, bingbot, applebot, duckduckbot, yandexbot] }
          description: Crawlers the bot cache serves. Empty means all of them.
        bot_cache_path_excludes:
          type: array
          items: { type: string }
          description: Wildcard path patterns (`/api/*`) never served from the bot cache.
        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 legacy TXT record that proves ownership of an external-DNS domain.
        Still accepted, but new domains verify with the CNAME in
        `ssl_delegation`, which also keeps their certificate renewing — prefer
        that one.
      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 }

    SslDelegation:
      type: object
      description: |
        An external-DNS domain's verification record: one CNAME, created once
        and never rotated. It proves ownership (a pending domain activates when
        it is found) and stays in place afterwards as the domain's connection
        to NSIN, which is what lets NSIN issue and renew the certificate for the
        apex and its wildcard. Once it has passed, removing or changing it
        marks the domain `moved` — it keeps serving for a 3-day grace period,
        then is disabled — exactly as a managed domain whose nameservers leave.
      properties:
        host: { type: string, description: Name to create the CNAME at, e.g. `_acme-challenge.example.com`. }
        type: { type: string, const: CNAME }
        value: { type: string, description: The CNAME target, unique to this domain. }
        ok:
          type: boolean
          description: Whether the last live check found this domain's delegation in place.
        checked_at: { type: string, format: date-time, description: When the last check ran. }
        ok_at: { type: string, format: date-time, description: When the delegation last passed. }
        error:
          type: string
          description: |
            Why the last check failed — or, with `ok` true, a CAA policy on the
            domain that prevents Let's Encrypt from issuing.
        next_manual_check_at: { type: string, format: date-time }
        auto_check_seconds:
          type: integer
          description: How often NSIN re-checks the record on its own.

    DomainDetail:
      allOf:
        - $ref: "#/components/schemas/Domain"
        - type: object
          properties:
            verification: { $ref: "#/components/schemas/DomainVerification" }
            ssl_delegation: { $ref: "#/components/schemas/SslDelegation" }
            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.
            billing_member:
              allOf:
                - $ref: "#/components/schemas/BillingMember"
              description: |
                Whose wallet this domain's charges come out of. Always present —
                it falls back to the owner — so a client can name the payer
                without a second request. Change it with
                `PUT /domains/{domain}/billing-member`.

    DomainSslSummary:
      type: object
      properties:
        status: { type: string, examples: ["active", "pending", "failed", "missing"] }
        expires_at: { type: string, format: date-time }
        days_remaining: { type: integer }
        uncovered_hostnames:
          type: array
          items: { type: string }
          description: |
            Proxied names of this domain with no certificate behind them, sorted.
            Absent when everything the domain proxies is covered. `status` still
            describes the certificate the domain does have, so a domain can be
            `valid` here and still list uncovered names.

    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 }
        hsts_enabled:
          type: boolean
          description: |
            See the `hsts_enabled` field on Domain. Cannot be undone for
            browsers that have already seen the header.
        hsts_max_age_sec:
          type: integer
          enum: [0, 300, 3600, 86400, 604800, 2592000, 31536000]
          description: |
            See the `hsts_max_age_sec` field on Domain. May only be raised one
            step per request; a bigger jump is rejected with `400` naming the
            next allowed value.
        hsts_include_subdomains: { type: boolean, description: See the `hsts_include_subdomains` field on Domain. }
        hsts_preload:
          type: boolean
          description: |
            See the `hsts_preload` field on Domain. Rejected with `400` unless
            `hsts_enabled`, `hsts_include_subdomains` and a `hsts_max_age_sec`
            of 31536000 are all in place after this update.
        min_tls_version:
          type: string
          enum: ["1.2", "1.3"]
          description: See the `min_tls_version` field on Domain.
        markdown_for_agents: { type: boolean }
        origin_protocol:
          type: string
          enum: [http1, http2, http3]
          description: See the `origin_protocol` field on Domain.
        default_static_cache: { type: boolean, description: See the `default_static_cache` field on Domain. }
        bot_cache_enabled:
          type: boolean
          description: |
            See the `bot_cache_enabled` field on Domain. Turning it on, or
            changing any `bot_cache_*` setting while it is on, requires the
            plan flag (`403` otherwise); turning it off never does.
        bot_cache_fresh_sec: { type: integer, minimum: 300, maximum: 86400 }
        bot_cache_max_age_sec:
          type: integer
          minimum: 300
          maximum: 86400
          description: Must be at least `bot_cache_fresh_sec`.
        bot_cache_kinds:
          type: array
          items: { type: string, enum: [googlebot, bingbot, applebot, duckduckbot, yandexbot] }
          minItems: 1
        bot_cache_path_excludes:
          type: array
          items: { type: string }
          maxItems: 50

    OriginProtocols:
      type: object
      description: |
        What a domain's origins speak, from a TLS handshake (ALPN), a QUIC
        handshake and one `HEAD /` request (for `Alt-Svc`) made from the NSIN
        control plane. A result proves the origin speaks a protocol; whether
        UDP is open on the path from a given edge is decided at the edge, which
        falls back to TCP per origin.
      properties:
        current:
          type: string
          enum: [http1, http2, http3]
          description: The domain's current `origin_protocol` setting.
        origins:
          type: array
          description: One entry per distinct origin (destination, port, scheme) across the domain's proxied records. Gateway records are excluded.
          items:
            type: object
            properties:
              host: { type: string }
              port: { type: integer }
              sni: { type: string, description: The server name presented in the handshake. }
              tls: { type: boolean, description: "`false` for a plain-HTTP origin, which can only be HTTP/1.1." }
              http1: { type: boolean }
              http2: { type: boolean }
              http3: { type: boolean }
              http3_via:
                type: string
                enum: [quic, alt-svc]
                description: How HTTP/3 support was established. Absent when `http3` is false.
              error: { type: string, description: Why the TCP/TLS handshake failed, when it did. }
              http3_error:
                type: string
                description: |
                  Why the QUIC handshake failed. Absent when it succeeded. Present
                  next to `http3_via` = `alt-svc` when the origin advertises HTTP/3
                  but the control plane could not reach it over UDP.
              checked_at: { type: string, format: date-time }
        supports:
          type: object
          description: Whether EVERY TLS origin answered the protocol.
          properties:
            http2: { type: boolean }
            http3: { type: boolean }
        recommended:
          type: string
          enum: [http1, http2, http3]
          description: The highest protocol every origin supports.
        probed_from:
          type: string
          example: control plane

    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.

    SslDelegationCheckResult:
      type: object
      properties:
        ok: { type: boolean }
        error: { type: string, description: Present when `ok` is false. }
        next_check_at: { type: string, format: date-time }
        ssl_delegation: { $ref: "#/components/schemas/SslDelegation" }
        domain: { $ref: "#/components/schemas/DomainDetail" }

    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
          description: |
            Whether NSIN renews this certificate itself. True for managed-DNS
            domains and for external-DNS domains whose `ssl_delegation` is in
            place; false for an external-DNS domain that uploads its own.
        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 }
        hostname:
          type: string
          description: |
            The name the reported certificate is installed under — usually the
            apex, but a domain may hold a certificate for one subdomain only.
        covered_hostnames:
          type: array
          items: { type: string }
          description: Every name of this domain with an active certificate, sorted.
        uncovered_hostnames:
          type: array
          items: { type: string }
          description: |
            Proxied names with no certificate behind them, sorted. External-DNS
            domains only — for managed domains the same gaps arrive in
            `coverage`, with the retry schedule that applies when we can issue.
        coverage:
          type: array
          description: |
            Proxied hostnames not yet on the certificate. Absent when coverage is
            complete.
          items: { $ref: "#/components/schemas/SslCoverageGap" }
        trusted:
          type: boolean
          description: |
            Whether the served certificate chains to a public root. `false` means
            no browser will accept it — a private CA (for example a Cloudflare
            Origin CA certificate), a self-signed certificate, or an internal
            PKI. Absent when the bundle has not been evaluated, which is a
            different state from `false` and should not be shown as a failure.
        trust_note:
          type: string
          description: |
            Why the certificate is flagged, phrased for the certificate's owner.
            Can be present even when `trusted` is true — an otherwise genuine
            certificate missing its intermediate is reported here.
        leaf_only:
          type: boolean
          description: |
            The bundle carries no intermediate certificate. Clients that do not
            already hold the issuer fail the handshake with "unable to get local
            issuer certificate". Independent of `trusted`. Absent when the bundle
            has not been evaluated.

    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. }
        managed_by:
          type: string
          description: |
            The platform feature that owns this record, or empty for a record
            you created. `email_routing` marks the MX, SPF and DKIM rows Email
            Routing publishes; they are removed by disabling routing, never
            through the record endpoints (which refuse them while locked).
        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
          minimum: 1
          maximum: 1800
          default: 15
          description: >
            How long an edge node waits for the origin to start responding
            before returning 504, in seconds. Default 15, maximum 1800 (30
            minutes). Only applies to proxied records.
        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 }
        edge_status:
          type: string
          enum: [ok, miss, error]
          description: |
            **External-DNS domains, proxied records only.** Whether the name
            resolves to NSIN's edge, as last checked: `ok` — every address it
            resolves to is ours; `miss` — it resolves elsewhere, only partly to
            us, or not at all; `error` — the first lookup failed and nothing is
            known yet. Absent until the first check, and always absent on a
            managed-DNS domain.
        edge_via:
          type: string
          enum: [cname, a, proxy]
          description: |
            How an `ok` record reaches NSIN: through the CNAME target
            (`cname`), by publishing our address directly (`a`), or `proxy` —
            DNS points at another CDN or proxy, but that service forwards to
            NSIN and the edge is serving the host (judged from the last 24h of
            request logs). A CNAME keeps working when our addresses change; an
            A record does not.
        edge_detail:
          type: string
          description: What the name actually resolves to, for display.
        edge_checked_at: { type: string, format: date-time }

    EdgeTarget:
      type: object
      description: |
        What an external-DNS owner should publish in their own zone so a
        proxied name reaches NSIN: always the shared hostname. `cname`
        (subdomains) is the hostname to CNAME at; `alias` (the apex, where a
        CNAME is not allowed) is the same hostname to use as an ANAME/ALIAS
        record. `ips` are the A addresses, carried only for the cases where no
        hostname applies — no target configured yet, or a record pinned to
        dedicated addresses (then neither hostname is set). A hostname keeps
        working when NSIN's addresses change; an A record does not.
      properties:
        cname: { type: string, example: edge.nsin.ir }
        alias: { type: string, example: edge.nsin.ir }
        ips:
          type: array
          items: { type: string }

    GatewayTerms:
      type: object
      description: |
        Per-domain acceptance of the gateway terms of use. `accepted` is false
        until someone with records-edit permission accepts them.
      properties:
        accepted: { type: boolean }
        accepted_at: { type: string, format: date-time }
        accepted_by_id: { type: integer }
        accepted_by_name: { type: string }
        accepted_by_email: { type: string }
        version:
          type: integer
          description: Revision of the terms that was accepted.

    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] }
        name:
          type: string
          description: The rule's optional label. Absent when the rule is unnamed.
        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" }
            edge_target:
              allOf:
                - $ref: "#/components/schemas/EdgeTarget"
              description: Only on a proxied record of an external-DNS domain.

    GatewayList:
      type: object
      description: |
        The gateway catalog for one domain, plus that domain's gateway quota.

        Gateway traffic is metered separately from the domain's own traffic: a
        plan allows a fixed number of gateway requests per ROLLING 30 days.
        There is no reset date — capacity returns gradually as older requests
        age out of the window. While the allowance is spent, only the gateway
        hostnames stop serving (they answer `429`); the rest of the domain is
        unaffected.
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Gateway" }
        available:
          type: boolean
          description: |
            Whether the domain's plan includes gateways. When false, switching a
            new gateway on is refused; gateways already on stay listed and can
            still be switched off.
        max_requests_30d:
          type: integer
          nullable: true
          description: Requests allowed in the rolling 30-day window. Null means unlimited.
        requests_30d:
          type: integer
          format: int64
          description: Requests served by this domain's gateways in the window, as of `counted_at`.
        quota_exceeded:
          type: boolean
          description: Whether the allowance is currently spent, so the gateways are answering 429.
        counted_at:
          type: string
          format: date-time
          description: When the count was last recomputed. Absent before the first count.
        dns_mode:
          type: string
          enum: [managed, external]
          description: |
            This domain's DNS mode. Gateways need `managed`: on an `external`
            domain the record would live in a zone NSIN does not serve, so
            switching one on is refused whatever the plan says. Gateways already
            on stay listed and can still be switched off.

    Gateway:
      type: object
      description: |
        One entry in the gateway catalog, plus whether it is switched on for the
        domain you asked about. The origin and upstream Host header behind a
        gateway are NSIN's and are not exposed.
      properties:
        id:
          type: integer
          description: Pass this as `gatewayId` to switch the gateway on or off.
        title: { type: string, description: Display name. }
        slug:
          type: string
          description: Prefix of the generated hostname — the record is named `<slug>-<5 digits>`.
        description: { type: string, description: Short explanatory line. May be empty. }
        upstream:
          type: string
          description: |
            The service this gateway forwards to, e.g. `api.openai.com`. Sent as
            the `Host` header upstream. The origin address behind it is not
            exposed.
          example: api.push.apple.com
        icon:
          type: string
          description: Icon as a base64 `data:` URI, ready to use as an `<img>` source. May be empty.
        enabled:
          type: boolean
          description: Whether this gateway is currently on for this domain.
        record:
          allOf:
            - $ref: "#/components/schemas/GatewayRecordRef"
          description: The record created for this gateway. Absent when `enabled` is false.

    GatewayRecordRef:
      type: object
      properties:
        id: { type: integer, description: Record id. }
        name: { type: string, description: "Name relative to the domain, e.g. `chatgpt-84213`." }
        original_name: { type: string, description: "Fully qualified name, e.g. `chatgpt-84213.example.com.`" }

    GatewayStat:
      type: object
      properties:
        gateway_id: { type: integer, description: The gateway this traffic belongs to. }
        hostname: { type: string, description: "Hostname the requests were made to, e.g. `chatgpt-84213.example.com`." }
        requests: { type: integer, description: Total requests over the period. }
        series:
          type: array
          description: One point per bucket, oldest first. The last point is the current, partial bucket.
          items:
            type: object
            properties:
              t: { type: string, format: date-time, description: Bucket start (UTC). }
              c: { type: integer, description: Requests in the bucket. }

    GatewayName:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: |
            Hostname relative to the domain — letters, digits and hyphens.
            Wildcards and `@` are rejected. On `apply` this field is optional;
            leave it out and a name is generated for you.
          example: my-chatgpt

    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 }
        ttl: { type: integer, minimum: 1, maximum: 604800, default: 120, description: "Seconds resolvers may cache this record. Forced to 120 while `proxied` is true — a proxied record answers with our edge addresses, and traffic can only be moved between edges as fast as the slowest resolver still holding the old answer." }
        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, minimum: 1, maximum: 1800, default: 15, description: "Origin wait before 504, in seconds. Max 1800 (30 minutes)." }
        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 }
        ttl: { type: integer, minimum: 1, maximum: 604800, default: 120, description: "Seconds resolvers may cache this record. Forced to 120 while `proxied` is true — a proxied record answers with our edge addresses, and traffic can only be moved between edges as fast as the slowest resolver still holding the old answer." }
        proxied: { type: boolean }
        captcha: { type: boolean }
        scheme: { $ref: "#/components/schemas/RecordScheme" }
        port: { type: integer }
        host_header: { type: string }
        monitor: { type: boolean }
        timeout: { type: integer, minimum: 1, maximum: 1800, description: "Origin wait before 504, in seconds. Max 1800 (30 minutes)." }
        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" }

    # -------------------------------------------------------------------------
    # Record import sessions
    # -------------------------------------------------------------------------

    ImportWarning:
      type: object
      description: |
        One reviewer-facing note on a staged row. `code` is stable — branch on
        it rather than on `message`, which is prose and may change.
      properties:
        code:
          type: string
          enum:
            [unsupported, not_proxyable_type, private_address, points_at_nsin,
             proxy_ineligible_origin, mail_host, non_web_service, overwrite,
             wildcard]
        severity:
          type: string
          enum: [info, warning, error]
          description: |
            Only `error` rows arrive unticked (`preselected: false`) — they are
            the ones NSIN cannot import at all.
        message: { type: string }

    ImportSessionRecord:
      type: object
      description: One record the scan found, with the verdict a reviewer needs.
      properties:
        id:
          type: integer
          description: |
            Staging row id. It is not a record id — it exists only until the
            session is committed or discarded.
        name: { type: string }
        type: { $ref: "#/components/schemas/RecordType" }
        destination: { type: string }
        ttl: { type: integer }
        mx_priority: { type: integer }
        proxied:
          type: boolean
          description: |
            Whether this row would be proxied — and, because a `mode: "all"`
            commit (the unattended `auto_commit_at` one included) writes exactly
            this flag, what happens to the row if nobody answers.

            It is `true` for the rows the edge can carry: a proxyable type on a
            routable origin. It is `false` wherever proxying would take a
            service away rather than accelerate it — a mail host, the target of
            an in-zone `MX`, an underscored name (`_acme-challenge`,
            `selector1._domainkey`), or a label that names something that does
            not speak HTTP (`ssh`, `vpn`, `mysql`, `ns1`, …). Those rows carry a
            `mail_host` or `non_web_service` warning saying so.

            An `overwrite` row mirrors the record it would replace: an import
            refreshes content and TTL and never changes an existing record's
            proxy setting.
        status:
          type: string
          enum: [new, overwrite, unsupported]
          description: |
            `overwrite` means the domain already holds a record with this name
            and type and it would be replaced. TXT is never `overwrite`: many
            TXT records share one name.
        existing_id:
          type: integer
          description: The record that would be replaced. Set when `status` is `overwrite`.
        proxy_eligible:
          type: boolean
          description: |
            Whether this row *could* be proxied if you switched it on — a
            proxyable type pointing at an address the edge will accept.
        preselected:
          type: boolean
          description: |
            Whether the row is included when you commit with `mode: "all"`.
            Rows carrying an `error` warning arrive unticked.
        warnings:
          type: array
          items: { $ref: "#/components/schemas/ImportWarning" }
        reason:
          type: string
          description: |
            The first warning's message, kept for clients written against the
            flat scan preview.

    ImportSessionCounts:
      type: object
      description: The header line of a review screen.
      properties:
        total: { type: integer }
        preselected: { type: integer, description: Rows a `mode "all"` commit would write. }
        proxied: { type: integer }
        warnings: { type: integer, description: Rows carrying at least one warning. }

    ImportSession:
      type: object
      description: |
        One scan-and-review cycle for a domain — the envelope every
        import-session endpoint returns.
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        status:
          type: string
          enum:
            [scanning, ready, committing, committed, auto_committed, discarded,
             superseded, failed]
          description: |
            `scanning` — the sweep is running. `ready` — waiting for a commit or
            a discard. `committing` — a commit is in flight and holds the claim.
            `auto_committed` — nobody answered before `auto_commit_at` and the
            records were written anyway, rather than leaving the zone empty.
            `superseded` — replaced by a rescan.
        source:
          type: string
          enum: [manual_scan, domain_create, zone_file]
        client:
          type: string
          enum: [panel, api]
          description: |
            What started the session. An API key has nowhere to show a review
            screen, so adding a domain with a key still imports automatically;
            staged review is the panel path.
        method:
          type: string
          enum: [axfr, scan]
          description: |
            Which branch found the records — a full zone transfer, or a resolver
            sweep of common names. The first support question about a bad import
            is where the records came from.
        source_ns:
          type: array
          items: { type: string }
          description: |
            The nameservers this scan read. Stored because a rescan has to reuse
            them: once the domain is delegated to NSIN, resolving it again finds
            our own — still empty — zone.
        error:
          type: string
          description: Why the scan, or the last commit attempt, failed.
        started_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time, description: When the scan finished and the session turned `ready`. }
        committed_at: { type: string, format: date-time }
        auto_commit_at:
          type: string
          format: date-time
          description: |
            When an unanswered `ready` session commits itself. Cleared by a
            commit, a discard or a rescan.
        counts: { $ref: "#/components/schemas/ImportSessionCounts" }
        records:
          type: array
          description: Empty while `status` is `scanning`.
          items: { $ref: "#/components/schemas/ImportSessionRecord" }

    ImportSessionCommit:
      type: object
      description: |
        Omit the body entirely to commit the session's own preselected rows —
        that is the same as `{"mode": "all"}`.
      properties:
        mode:
          type: string
          enum: [all, selected]
          default: all
          description: |
            `selected` commits `records` exactly as posted. `all` — or an empty
            `records` — commits the rows the session preselected, with the proxy
            defaults it staged.
        records:
          type: array
          description: |
            The reviewed set. Rows may be dropped, and `proxied` may be switched
            on per row, but a row must still be one the plan and the address
            rules accept.
          items: { $ref: "#/components/schemas/ImportRecordItem" }

    ImportSessionCommitResult:
      type: object
      properties:
        created: { type: integer }
        overwritten: { type: integer, description: Existing records replaced by a staged row of the same name and type. }
        failed:
          type: array
          description: |
            Per-row failures. They never abort the batch — everything else was
            still written.
          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 commit.
          items: { $ref: "#/components/schemas/Record" }

    ImportSessionConflict:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            status:
              type: string
              description: |
                The session's status as it stands now, so a loser of the claim
                race can tell "already committed" from "discarded".

    ImportScanThrottledError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            retry_after_seconds:
              type: integer
              description: Seconds left on this domain's five-minute scan window.

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

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

    HostMatchType:
      type: string
      description: |
        How the host filter — `host_pattern`, `host_includes` and
        `host_excludes` — is matched. One strategy covers all three, exactly as
        one `path_match_type` covers both path lists.

        The empty string means "no host filter", and is the only valid value
        when the pattern and both lists are empty. Set a list without a match
        type and the API defaults it to `wildcard`.

        A `wildcard` entry matches subdomains, not the label itself:
        `*.example.com` covers `shop.example.com` but not `example.com` — the
        same reading as the DNS wildcard. Add the bare name as its own entry to
        include it.
      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: |
            Legacy single-hostname filter, kept for rules written before
            `host_includes` existed. It is evaluated as one more entry of
            `host_includes`; prefer the lists.
        host_match_type: { $ref: "#/components/schemas/HostMatchType" }
        host_includes:
          type: array
          items: { type: string }
          description: |
            Hostnames the rule applies to. Empty or absent means every host the
            scoped record(s) serve — which on a wildcard-proxied zone
            (`*.example.com`) is every subdomain.
        host_excludes:
          type: array
          items: { type: string }
          description: |
            Hostnames carved back out of `host_includes`. An exclude always
            wins over an include, so "everything except staging" is an empty
            include list plus one exclude.
        ip_includes:
          type: array
          items: { type: string }
          maxItems: 256
          description: |
            Client addresses the rule applies to. Empty or absent means every
            address — the rule is not scoped by IP at all unless `ip_excludes`
            is set.

            Entries are returned canonicalised, which may differ from what was
            sent: CIDR host bits are masked off (`10.0.0.5/8` reads back as
            `10.0.0.0/8`), range endpoints are ordered low-to-high, IPv4-mapped
            IPv6 is unmapped, and duplicates are dropped. The order of the
            remaining entries is preserved.

            The address matched is the TCP peer seen by the edge. `X-Forwarded-For`
            is never consulted, so a visitor cannot spoof a header to put
            themselves inside or outside a scope.
        ip_excludes:
          type: array
          items: { type: string }
          maxItems: 256
          description: |
            Client addresses carved back out of `ip_includes`. An exclude always
            wins over an include. Canonicalised on the way in, exactly like
            `ip_includes`.
        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
          nullable: true
          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" }
        host_includes:
          type: array
          items: { type: string }
          maxItems: 200
          description: |
            Hostnames the rule applies to; empty or omitted means every host.
            Sending an explicit empty array clears an existing list.
        host_excludes:
          type: array
          items: { type: string }
          maxItems: 200
          description: Hostnames excluded from the rule; excludes beat includes.
        ip_includes:
          type: array
          items: { type: string }
          maxItems: 256
          description: |
            Scope the rule to these client addresses. Empty or omitted means
            every address; sending an explicit empty array clears an existing
            list.

            Together with `ip_excludes` these two lists express all four
            client-IP semantics, so there is no separate match-type field:

            * **IP is in a list** — put the addresses in `ip_includes`.
            * **IP is not in a list** — put them in `ip_excludes` and leave
              `ip_includes` empty.
            * **IP equals X** — a one-entry `ip_includes`.
            * **IP does not equal X** — a one-entry `ip_excludes`.

            Each entry is one of three forms:

            * a bare address, IPv4 or IPv6 — `203.0.113.7`, `2001:db8::1`
            * a CIDR prefix — `203.0.113.0/24`, `2001:db8::/32`
            * an inclusive low-high range — `203.0.113.10-203.0.113.40`. Both
              endpoints must be the same IP version.

            Entries are canonicalised and de-duplicated on save (CIDR host bits
            masked off, range endpoints ordered, IPv4-mapped IPv6 unmapped), so
            the stored value is exactly what the edge will match. An entry that
            is not a valid address, CIDR or range is a `400`.

            An empty `ip_includes` means **every** address, subject to
            `ip_excludes`; an exclude always beats an include. Matching is
            against the TCP peer at the edge, never `X-Forwarded-For`, so the
            scope cannot be spoofed with a request header.

            **Not accepted on `cache` and `optimize` rules** — a non-empty list
            on either type is a `400`. One cached copy is shared by every visitor
            and the cache key carries no client-IP dimension, so an IP-scoped
            cache rule would serve one visitor's response to another.

            On a `drop` rule, `ip_excludes` may not be used on its own:
            exclude-only would blackhole every visitor who is not named, so
            `ip_includes` must list at least one address.
        ip_excludes:
          type: array
          items: { type: string }
          maxItems: 256
          description: |
            Client addresses excluded from the rule; excludes beat includes.
            Same three entry forms, same canonicalisation and same `cache` /
            `optimize` refusal as `ip_includes`.
        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.
        ip_match_type:
          type: string
          enum: [include, exclude]
          default: include
          description: |
            Whether `ips` is the set that IS dropped (`include`) or the only set
            that is NOT dropped (`exclude` — an allowlist). An `exclude` rule
            must list at least one entry; an empty allowlist would drop every
            request on the matched paths.
        ips:
          type: array
          items: { type: string }
          maxItems: 256
          description: |
            Client addresses the rule is scoped to. Empty means no IP filter.
            Each entry is a single address (`203.0.113.7`, `2001:db8::1`), a
            CIDR prefix (`10.0.0.0/8`), or an inclusive range
            (`10.0.0.1-10.0.0.50`). Entries are stored canonicalized — CIDR
            host bits are masked off and reversed ranges are ordered.

            Under `exclude`, a visitor whose address cannot be determined is
            dropped: it is provably not one of the allowed addresses.

    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"
        - type: object
          properties:
            www_record:
              type: string
              enum: [covered, created, created_external, unproxied, no_apex, limit, failed]
              description: |
                Only present when *creating* the canonical `www.<domain>` →
                apex (or apex → `www.<domain>`) redirect. Those rules are inert
                without a proxied DNS record for `www` — the edge evaluates
                rules only for hostnames it holds a record for — so the record
                is provisioned alongside the rule and this reports what
                happened: `created` (a proxied www record mirroring the apex
                was added), `created_external` (added, but the zone is hosted
                elsewhere so the owner must still point `www` at us),
                `covered` (one already existed), `unproxied` (a www
                record exists but bypasses the edge, so the redirect will not
                run), `no_apex` (no apex address record to mirror), `limit`
                (the plan is out of record slots), `failed` (the DNS write
                failed). The rule itself is created in every case.

    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 — header transform
    # -------------------------------------------------------------------------

    HeaderOp:
      type: object
      required: [direction, action, name]
      description: |
        One header edit. A rule carries an ordered list of them and applies
        them in slice order, so a later op beats an earlier one on the same
        name.
      properties:
        direction:
          type: string
          enum: [request, response]
          description: |
            * `request` — applied on the way to the origin, after NSIN has
              stamped its own forwarding headers.
            * `response` — applied on the way to the visitor, after the
              response is retrieved from cache or origin. Nothing a response op
              does reaches the stored copy, so editing a rule takes effect on
              the very next cache hit rather than when the object expires.

            A 101 WebSocket handshake never passes the response chokepoint, so
            response ops do not apply to an upgrade.
        action:
          type: string
          enum: [set, add, remove]
          description: |
            * `set` — replace every existing value with `value`.
            * `add` — append `value`, keeping what the client or origin sent.
            * `remove` — delete the header. The only action that accepts a
              trailing `*` wildcard in `name`.
        name:
          type: string
          maxLength: 256
          description: |
            The header field name — an RFC 9110 token, matched
            case-insensitively. A trailing `*` (`X-Debug-*`) is a prefix
            wildcard and is accepted on `remove` only; the edge re-checks the
            blocked list against every name a wildcard actually matches, so a
            wildcard can never strip a header NSIN owns.

            Some names are refused per `(direction, action)` rather than
            outright — see `ops` on the request body for the list and the
            reasoning.
        value:
          type: string
          maxLength: 4096
          description: |
            Required for `set` and `add`, and must be empty for `remove`.

    HeaderRuleFields:
      type: object
      properties:
        name:
          type: string
          maxLength: 64
          description: |
            Optional label for this rule, e.g. "Security headers", shown in the
            panel's rule table. Purely for identification. Omit or send an
            empty string for none.
        ops:
          type: array
          minItems: 1
          maxItems: 16
          items: { $ref: "#/components/schemas/HeaderOp" }
          description: |
            The ordered edit list, applied in array order.

            **Caps**, all validated on save so an oversized rule fails as a
            clear `400` instead of as a 431 from the origin: 16 ops per rule,
            4096 bytes per `value`, and 8192 bytes summed over name+value of
            every `set`/`add` op — 4 KB leaves room for a real
            Content-Security-Policy while staying under the 8 KB per-header
            limit nginx and Apache default to.

            **Headers NSIN owns are refused**, per `(direction, action, name)`
            rather than by name alone. On the request: `Host`, the framing and
            hop-by-hop set (`Content-Length`, `Transfer-Encoding`,
            `Connection`, `Upgrade`, `Keep-Alive`, `Proxy-Connection`, `TE`,
            `Trailer`), the forwarding set (`X-Forwarded-*`, `X-Real-IP`,
            `Forwarded`, `True-Client-IP`, `CDN-Loop`), the conditional and
            negotiation set (`Cache-Control`, `If-None-Match`,
            `If-Modified-Since`, `If-Match`, `If-Range`, `Range`,
            `Accept-Encoding`), `Sec-WebSocket-*`, `X-Mafar-*`, `X-Nsin-*` and
            the whole `Nsn-*` prefix. On the
            response: `Content-Length`, the hop-by-hop set,
            `Content-Encoding`, `Vary`, `Server`, `Strict-Transport-Security`
            (managed by the HSTS ramp on the domain's Security page) and again
            `Nsn-*`.

            Three request headers are blocked for `set`/`add` but **allowed for
            `remove`**: `Cookie`, `Authorization` and `Nsn-Connecting-IP`.
            Re-adding a credential is what makes a personalised page cacheable
            in a shared slot; stripping one before it reaches the origin is a
            legitimate privacy choice, and is how an owner makes a page
            cacheable that otherwise would not be.

            Each refusal returns a `400` naming the header, the action and the
            reason.

            Two response names are **accepted but dangerous**, and the panel
            makes a human type the header name to confirm before saving one:
            `Cache-Control`, which desyncs the browser's view of freshness from
            the edge's, and `Set-Cookie`, which on a cacheable page is the shape
            of a cross-account session leak. The API applies no confirmation
            step — an integration writing either one should know why.

    HeaderRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/HeaderRuleFields"

    HeaderRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/HeaderRuleFields"
        - type: object
          description: |
            `ip_includes` / `ip_excludes` are refused with a `400` when any op
            in the rule is request-direction: a request op changes what is
            fetched from the origin without changing the cache key, so the
            first fill would win for every visitor in every other IP bucket —
            the same defect that bars an IP scope on `cache` and `optimize`.
            Response-direction ops are applied per request after retrieval and
            keep full IP-scope parity.

    # -------------------------------------------------------------------------
    # Rules — web optimization
    # -------------------------------------------------------------------------

    OptimizeRuleFields:
      type: object
      properties:
        images:
          type: boolean
          default: false
          description: |
            Convert JPEG and PNG responses to WebP. Only visitors whose
            `Accept` header advertises WebP are served it — those requests
            occupy a separate cache slot — so a client that cannot decode WebP
            always receives the original file.
        image_quality:
          type: integer
          minimum: 40
          maximum: 100
          default: 80
          description: |
            WebP encoder quality. Lower is smaller and lossier. 80 is the
            recommended balance.
        minify_js:
          type: boolean
          default: false
          description: |
            Strip comments and whitespace from JavaScript. Identifiers are
            never renamed.

            Note: minifying a script breaks any page that loads it with a
            Subresource Integrity hash (`integrity="sha384-..."`), because the
            bytes no longer match, and it invalidates published source maps.
            The edge cannot detect either condition.
        minify_css:
          type: boolean
          default: false
          description: Strip comments and whitespace from CSS.
        compress_level:
          type: integer
          minimum: 0
          maximum: 11
          default: 0
          description: |
            Brotli quality for cached text responses. `0` inherits the node
            default. Lossless, so it carries none of the risk of the other
            actions. Higher levels are applied in the background after the
            first response is served, so they never add latency.

    OptimizeRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/OptimizeRuleFields"

    OptimizeRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/OptimizeRuleFields"

    # -------------------------------------------------------------------------
    # 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.
        mode:
          type: string
          enum: [full, critical_only]
          default: full
          description: |
            `full` runs the whole Core Rule Set with anomaly scoring (paranoia,
            threshold, body cap and excludes apply). `critical_only` checks only
            NSIN's short critical set — secret-file probes, path traversal, OS
            file access, Log4Shell, known scanner tools — over the URL, query
            string and headers; any single hit blocks and the body is never
            read. The default NSIN Shield rule uses `critical_only`.
        managed_by:
          type: string
          readOnly: true
          description: |
            `shield` on the default NSIN Shield rule the platform created for
            this domain; empty on rules you created. A managed rule can be
            edited, disabled or deleted like any other.

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

    WafShieldStatus:
      type: object
      description: State of the domain's default NSIN Shield rule.
      properties:
        plan_allows:
          type: boolean
          description: The domain's current plan includes default protection.
        present:
          type: boolean
          description: A managed shield rule exists for this domain (it may be disabled).
        rule_id: { type: integer }
        enabled: { type: boolean }
        action_mode:
          type: string
          enum: [enforce, dry_run]
        dismissed:
          type: boolean
          description: The rule was deleted by the owner and will not be re-created unless restored.
        dismissed_at: { type: string, format: date-time }
        active:
          type: boolean
          description: Present, enabled, and shipped to the edge.
        default_action_mode:
          type: string
          enum: [enforce, dry_run]
          description: The mode a restored rule is created with.

    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 — basic auth
    # -------------------------------------------------------------------------

    BasicAuthUser:
      type: object
      description: A credential as it is returned — username only.
      properties:
        username: { type: string }
        has_password:
          type: boolean
          description: Always true for a usable credential. Passwords are never returned.

    BasicAuthUserInput:
      type: object
      required: [username]
      properties:
        username:
          type: string
          maxLength: 64
          description: 'Must not contain `:` (RFC 7617 forbids it in the user-id).'
        password:
          type: string
          minLength: 8
          maxLength: 128
          description: |
            Write-only. Omit it for a username that already exists to keep that
            user's current password; required for a new username.

    BasicAuthCommonFields:
      type: object
      properties:
        realm:
          type: string
          maxLength: 128
          default: Restricted
          description: |
            Shown in the browser's sign-in prompt. Must not contain `"`, `\`, or
            control characters.
        bypass_cidrs:
          type: array
          maxItems: 32
          items: { type: string }
          description: |
            IPs or CIDRs whose requests skip the prompt entirely — an office
            network, an uptime monitor. Bare IPs are stored as a full-length
            prefix (`203.0.113.7` → `203.0.113.7/32`).

    BasicAuthRule:
      allOf:
        - $ref: "#/components/schemas/RuleCommon"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/BasicAuthCommonFields"
        - type: object
          properties:
            users:
              type: array
              items: { $ref: "#/components/schemas/BasicAuthUser" }

    BasicAuthRuleBody:
      allOf:
        - $ref: "#/components/schemas/RuleCommonBody"
        - $ref: "#/components/schemas/RulePathScope"
        - $ref: "#/components/schemas/BasicAuthCommonFields"
        - type: object
          properties:
            users:
              type: array
              minItems: 1
              maxItems: 32
              items: { $ref: "#/components/schemas/BasicAuthUserInput" }
              description: |
                The complete credential list. Sending it replaces what is
                stored, so any username you leave out is removed.

    # -------------------------------------------------------------------------
    # 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`.
            Under `ha` it still applies, weighting round-robin *within* a
            priority tier. Defaults to 1.
        priority:
          type: integer
          default: 1
          minimum: 1
          maximum: 100
          description: |
            Failover tier under `lb_type: ha`. **Lower is preferred** — 1 is the
            primary tier, 2 the first standby, and so on, the same direction as
            the rule's own `priority`. Origins sharing a number form one tier
            and load-balance across it.

            Only `ha` reads this field; the other strategies ignore it and leave
            it untouched, so switching `lb_type` back and forth loses nothing.
            Omitted or `0` is normalised to 1.
        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, ha]
          default: round_robin
          description: |
            How traffic is spread across `origins`.

            * `round_robin` — weighted rotation across every origin.
            * `least_load` — the origin with the fewest in-flight requests.
            * `geo` — routes by edge node; see `node_ids` on each origin.
            * `ha` — active/passive failover. All traffic goes to the lowest
              `priority` tier that still has a healthy origin, and cascades to
              the next tier only once a whole tier is down.

            `ha` has two extra requirements, each a `400` when unmet:

            * at least **two distinct** `origins[].priority` values — a single
              tier has nothing to fail over to;
            * `health_check.enabled` must be `true`. Standby origins take no
              real traffic, so without active probes the edge can never observe
              the primary going down, nor the standby recovering, and would pin
              every request to tier 1 forever.
        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.
        name:
          type: string
          maxLength: 64
          description: |
            Optional label for this pool, shown in the panel's rule table and
            next to the records it overrides. Purely for identification — it
            does not affect routing. Omit or send an empty string for none.

    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."
        name:
          type: string
          maxLength: 64
          description: |
            Optional label for this route, shown in the panel's rule table and
            next to the records it overrides. Purely for identification — it
            does not affect routing. Omit or send an empty string for none.

    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."
        domains: { type: integer, description: How many domains contributed. }

    GlobalSummaryPeak:
      type: object
      properties:
        peak_rps:
          type: integer
          description: Highest request count in any single second of the period.

    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 }

    TlsSummary:
      type: object
      properties:
        requests: { type: integer, description: TLS-terminated requests in the period. }
        resumed: { type: integer, description: Requests whose TLS session was resumed rather than negotiated afresh. }
        resumption_rate: { type: number, description: "`resumed / requests`, as a percentage; `0` when there were no TLS requests." }

    TlsVersionStats:
      type: object
      properties:
        version: { type: string, description: "Negotiated version, e.g. `TLSv1.3`." }
        requests: { type: integer }
        pct: { type: number, description: Percentage share of all TLS requests. }

    TlsCipherStats:
      type: object
      properties:
        cipher: { type: string, description: "Negotiated cipher suite, e.g. `TLS_AES_128_GCM_SHA256`." }
        requests: { type: integer }
        pct: { type: number, description: Percentage share of the returned suites, which sum to 100%. }

    AiCrawlerSummary:
      type: object
      properties:
        requests: { type: integer }
        allowed: { type: integer, description: Requests answered with a status below `400`. }
        unsuccessful: { type: integer, description: Requests answered with `4xx` or `5xx`. }
        bytes: { type: integer, description: Bytes served to crawlers. }
        markdown_answered: { type: integer, description: Responses the edge rewrote to Markdown. }
        markdown_missed: { type: integer, description: Eligible responses that were not rewritten. }
        markdown_eligible: { type: integer, description: Responses that could plausibly have been Markdown — status below `300`. }

    AiCrawlerStats:
      type: object
      properties:
        kind: { type: string, description: "Bot kind, e.g. `gptbot` or `claudebot`." }
        requests: { type: integer }
        allowed: { type: integer }
        unsuccessful: { type: integer }
        bytes: { type: integer }
        markdown: { type: integer, description: Responses served to this crawler as Markdown. }
        last_seen: { type: string, format: date-time, description: Most recent request from this crawler in the period. }

    AiCrawlerDataPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        requests: { type: integer }
        allowed: { type: integer }
        unsuccessful: { type: integer }
        bytes: { type: integer }
        s2xx: { type: integer }
        s3xx: { type: integer }
        s4xx: { type: integer }
        s5xx: { type: integer }

    AiCrawlerPath:
      type: object
      properties:
        path: { type: string, description: URL path, without the query string. }
        hostname: { type: string }
        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.

    BotCacheAnalytics:
      type: object
      properties:
        requests: { type: integer, description: Requests from verified search-engine crawlers. }
        hits: { type: integer }
        misses: { type: integer }
        bypass: { type: integer }
        hit_rate: { type: number, description: "`hits / (hits + misses)` as a percentage; `0` with no cache lookups." }
        by_kind:
          type: array
          description: The same counters per crawler, busiest first.
          items:
            type: object
            properties:
              kind: { type: string, example: googlebot }
              requests: { type: integer }
              hits: { type: integer }
              misses: { type: integer }
              bypass: { type: integer }
              hit_rate: { type: number }

    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 }

    NodeSeriesPoint:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        count: { type: integer }
        bytes: { type: integer, description: Egress bytes in the bucket. }

    NodeDomainStats:
      type: object
      properties:
        domain_id: { type: integer }
        domain_name: { type: string, description: Empty when the traffic matched no registered domain. }
        requests: { type: integer }
        bandwidth: { type: integer, description: Egress bytes. }

    NodeOverviewItem:
      type: object
      properties:
        node:
          type: string
          description: Node identifier. Empty when the serving edge reported no node name.
        node_label: { type: string, description: Human-readable name. Absent for a node that is not registered. }
        country: { type: string, description: ISO country code. }
        registered: { type: boolean, description: Whether the name matches a registered edge node. }
        active: { type: boolean, description: Whether the registered node is in service. }
        requests: { type: integer }
        bandwidth: { type: integer, description: Egress bytes. }
        bytes_in: { type: integer, description: Bytes received from clients. }
        unique_visitors: { type: integer }
        error_rate:
          type: number
          description: "Percentage of requests that returned 4xx or 5xx, 0–100."
        errors_5xx: { type: integer }
        cache_hit_rate:
          type: number
          description: "Percentage of cache lookups served from cache, 0–100."
        cached_requests: { type: integer }
        avg_duration:
          type: number
          description: "Mean response time in milliseconds, WebSocket requests excluded."
        p95_duration:
          type: number
          description: "95th percentile response time in milliseconds, WebSocket requests excluded."
        latest_event:
          type: string
          format: date-time
          description: Most recent request this node served in scope. Null when it served none.
        series:
          type: array
          description: Traffic over time, one bucket per period step.
          items: { $ref: "#/components/schemas/NodeSeriesPoint" }
        top_domains:
          type: array
          description: The busiest domains on this node, at most five.
          items: { $ref: "#/components/schemas/NodeDomainStats" }

    NodesOverviewResponse:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/NodeOverviewItem" }
        totals:
          type: object
          description: What the per-node rows add up to over the same scope.
          properties:
            requests: { type: integer }
            bandwidth: { type: integer, description: Egress bytes. }
            nodes_with_traffic: { type: integer }
            unattributed_requests:
              type: integer
              description: Requests whose row carried no node name.

    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 }
        monitor_id:
          type: integer
          description: |
            The monitor that fired. Absent for the domain's default whole-host
            watch. Two monitors on one hostname are otherwise indistinguishable
            in the history.
        scope_label:
          type: string
          description: |
            Human name of the watch — the monitor's `name`, or its first
            included path when it has none. Empty string for the whole-host
            watch.
        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 }
        monitor_id:
          type: integer
          description: |
            The monitor this row belongs to; `0` for the domain's default
            whole-host watch. One hostname can appear on several rows — one per
            matching monitor, plus the whole-host row — so key on
            (`hostname`, `monitor_id`), never on `hostname` alone.
        monitor_name: { type: string, description: Empty for the whole-host watch. }
        scope_label:
          type: string
          description: |
            Human name of the watch, so two rows for one hostname do not read as
            duplicates. Empty for the whole-host watch.
        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 scope. }

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

    UptimeActive:
      type: object
      properties:
        count: { type: integer, description: Number of outage incidents currently open. }
        hostnames:
          type: array
          description: The subdomains that are down right now.
          items: { type: string }
        since:
          type: string
          format: date-time
          description: When the oldest open incident started. Absent when nothing is down.

    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. }
        uptime_host_match_type:
          type: string
          description: |
            How `uptime_host_includes` / `uptime_host_excludes` are read. Empty
            when no host filter is set, which is the default and means every
            subdomain is watched.
          enum: ["", exact, wildcard, regex]
        uptime_host_includes:
          type: array
          nullable: true
          description: |
            Which subdomains the default whole-host watch covers. `null` or empty
            means all of them. Same syntax as a rule's host scope.
          items: { type: string }
        uptime_host_excludes:
          type: array
          nullable: true
          description: Subdomains excluded from the whole-host watch. Excludes beat includes.
          items: { type: string }
        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 }
        uptime_host_match_type:
          type: string
          description: |
            Required to be `exact`, `wildcard` or `regex` once either host list
            is non-empty; it is forced back to empty when both lists are cleared.
          enum: ["", exact, wildcard, regex]
        uptime_host_includes:
          type: array
          description: |
            Send an explicit `[]` to clear the list — omitting the field leaves
            it unchanged.
          items: { type: string }
          maxItems: 200
        uptime_host_excludes:
          type: array
          description: Send an explicit `[]` to clear the list.
          items: { type: string }
          maxItems: 200

    UptimeMonitor:
      type: object
      description: |
        A path-scoped uptime watch. It runs the SAME detector as the whole-host
        watch — same thresholds, same window, same incident machinery — narrowed
        to the host and path scope declared here. Thresholds are inherited from
        the domain's uptime settings and cannot be overridden per monitor.
      properties:
        id: { type: integer }
        domain_id: { type: integer }
        name:
          type: string
          description: |
            Optional label, up to 64 characters. It is what the outage SMS and
            the incident list call this watch, so a domain with two monitors on
            one host sends two distinguishable alerts.
        enabled: { type: boolean }
        host_pattern:
          type: string
          description: |
            Legacy single-entry host scope, kept for parity with rules. It is
            folded into `host_includes` when the scope is compiled.
        host_match_type:
          type: string
          description: Empty when the monitor has no host scope, i.e. it watches every subdomain.
          enum: ["", exact, wildcard, regex]
        host_includes:
          type: array
          nullable: true
          description: '`null` or empty means every subdomain of this domain.'
          items: { type: string }
        host_excludes:
          type: array
          nullable: true
          description: Excludes beat includes.
          items: { type: string }
        path_match_type:
          type: string
          description: How the path lists are read.
          enum: [wildcard, regex]
        path_includes:
          type: array
          description: |
            Request paths this monitor watches. Defaults to `["/*"]` — every
            path — when you send an empty list.
          items: { type: string }
        path_excludes:
          type: array
          description: Paths dropped from the scope. Excludes beat includes.
          items: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    UptimeMonitorList:
      type: object
      properties:
        monitors:
          type: array
          items: { $ref: "#/components/schemas/UptimeMonitor" }
        max:
          type: integer
          description: |
            How many monitors this domain may have. A detection-cost ceiling —
            every monitor adds one path match per scanned log row per tick — not
            a plan entitlement, so it is the same number for every plan.

    UptimeMonitorBody:
      type: object
      description: |
        Every field is optional. On create, omitted fields take their default;
        on update, omitted fields keep their current value and an explicit `[]`
        clears a list.

        A monitor must narrow SOMETHING: one with no host scope and a path scope
        of every path duplicates the whole-host watch (a second incident and a
        second SMS for the same outage) and is rejected. Regex entries are
        compiled at write time — an uncompilable pattern is refused here rather
        than breaking detection later.
      properties:
        name: { type: string, maxLength: 64 }
        enabled: { type: boolean, default: true }
        host_pattern: { type: string }
        host_match_type:
          type: string
          description: |
            Required to be `exact`, `wildcard` or `regex` once a host filter is
            set; defaults to `wildcard` if you set one without saying how to
            read it.
          enum: ["", exact, wildcard, regex]
        host_includes:
          type: array
          items: { type: string }
          maxItems: 200
        host_excludes:
          type: array
          items: { type: string }
          maxItems: 200
        path_match_type:
          type: string
          default: wildcard
          enum: [wildcard, regex]
        path_includes:
          type: array
          description: 'Defaults to `["/*"]` when empty.'
          items: { type: string }
          maxItems: 50
        path_excludes:
          type: array
          items: { type: string }
          maxItems: 50

    # -------------------------------------------------------------------------
    # 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. New checks are
            added over time, so treat this as an open set rather than a closed
            enum.
          enum:
            - cache_off
            - slow_response
            - errors_5xx
            - images_not_webp
            - ssl_expiring
            - no_robots
            - no_sitemap
            - no_https_redirect
            - no_records
            - apex_unreachable
            - www_unreachable
            - www_redirect_bypassed
            - proxy_none
            - proxy_off
            - ai_markdown
            - origin_protocol
            - gateway_quota
            - traffic_quota
            - external_dns_unreachable
            - external_dns_use_cname
          example: cache_off
        status: { type: string, enum: [ok, warn], description: '`ok` is a passing check; `warn` needs action.' }
        severity:
          type: string
          enum: [high, medium, low]
          description: |
            Also decides dismissal: `low` can be hidden for good, `medium` for
            7 days, `high` not at all.
        category:
          type: string
          enum: [speed, seo, reachability, security, plan]
          description: |
            `plan` covers the allowances a subscription buys — gateway quota and
            included traffic.
        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."
        dismissible:
          type: boolean
          description: |
            Whether this item may be dismissed at all. False for every passing
            check and for every `high` warning — dismissing one of those is
            refused with `409`, and has no effect even if a row exists.
        dismissed_until:
          type: string
          format: date-time
          description: |
            Present only while a time-limited (`medium`) dismissal is in force:
            when the item comes back. A `low` dismissal never expires, so it has
            no date.

    # -------------------------------------------------------------------------
    # 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:
          allOf:
            - $ref: "#/components/schemas/NotifyMatrix"
          description: |
            The member's effective notification matrix. Always all-on for the
            owner, who receives every category and cannot opt out.
        notify_defaulted:
          type: object
          description: |
            Category → the channels still UNSET for this member, i.e. inherited
            from their role rather than chosen. Render them as inherited, not as
            a box the member ticked: a role change moves them.
          additionalProperties:
            type: array
            items: { $ref: "#/components/schemas/NotifyChannel" }
        is_billing_member:
          type: boolean
          description: True for the domain's nominated payer.

    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:
          type: object
          description: |
            Category → channel → tri-state. `true`/`false` records an explicit
            choice; **`null` deletes** the stored choice so the pair falls back
            to the member's role default — that is how you reset a switch rather
            than pinning it off. Unknown categories or channels are rejected, so
            a typo can never read as "no change made".
          additionalProperties:
            type: object
            additionalProperties: { type: ["boolean", "null"] }

    # -------------------------------------------------------------------------
    # Who pays for a domain
    # -------------------------------------------------------------------------

    BillingMember:
      type: object
      description: |
        Whose wallet this domain's charges come out of. Always populated — with
        no nomination in force it describes the owner.
      properties:
        user_id: { type: integer }
        name: { type: string }
        email: { type: string }
        set_by:
          type: integer
          description: Who made the nomination. Absent when the owner pays.
        set_at: { type: string, format: date-time }
        is_owner:
          type: boolean
          description: |
            True when no nomination is in force and the domain owner pays. The
            owner being named explicitly is stored as *no* nomination, so this
            never disagrees with ownership.

    BillingMemberError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            code:
              type: string
              enum: [not_a_member]
              description: |
                Present when the nominee has no accepted membership on this
                domain — so a client can offer "invite them first" instead of
                just printing the message.

    DomainPayer:
      type: object
      description: |
        The wallet a purchase or renewal on this domain will spend, and what is
        in it.
      properties:
        user_id: { type: integer }
        name: { type: string, description: Display name, falling back to the email address. }
        email: { type: string }
        is_owner: { type: boolean, description: True when the payer is the domain owner. }
        is_self: { type: boolean, description: True when the payer is the account this key belongs to. }
        balance_rials:
          type: integer
          format: int64
          description: |
            The payer's wallet balance in rials — disclosed only through this
            gate, and only for this domain's payer.

    # -------------------------------------------------------------------------
    # Member notifications
    # -------------------------------------------------------------------------

    NotifyCategory:
      type: string
      description: |
        A class of event a member can subscribe to: domain status, origin
        outages, certificates, plan and subscription changes, gateways, and
        invoices.
      enum: [domain, uptime, ssl, plan, gateway, invoice]

    NotifyChannel:
      type: string
      description: |
        An outbound delivery channel a member can switch off. The in-app feed is
        deliberately not one — see `in_app_always_on`.
      enum: [email, sms]

    NotifyMatrix:
      type: object
      description: |
        Category → channel → enabled. The **effective** matrix: explicit choices
        merged over the member's role defaults, so a client never has to
        reimplement the fallback.
      additionalProperties:
        type: object
        additionalProperties: { type: boolean }

    NotifyCatalog:
      type: object
      properties:
        categories:
          type: array
          items: { $ref: "#/components/schemas/NotifyCategory" }
        channels:
          type: array
          items: { $ref: "#/components/schemas/NotifyChannel" }
        role_defaults:
          type: object
          description: |
            Grantable role → category → whether a member holding that role
            receives the category when they have chosen nothing. The default
            applies to every channel.
          additionalProperties:
            type: object
            additionalProperties: { type: boolean }
        in_app_always_on:
          type: boolean
          description: |
            Always `true`. The in-app feed cannot be switched off — say so in
            your UI, or the first question will be why muted notifications still
            appear.

    MemberUpdateResult:
      type: object
      description: |
        The membership after a patch. Narrower than `Member` — it carries the
        membership itself and the recomputed notification matrix, not the user
        profile you already have from the member list.
      properties:
        id: { type: integer, description: Membership id. }
        domain_id: { type: integer }
        user_id: { type: integer }
        role: { $ref: "#/components/schemas/Role" }
        notify: { $ref: "#/components/schemas/NotifyMatrix" }
        notify_defaulted:
          type: object
          additionalProperties:
            type: array
            items: { $ref: "#/components/schemas/NotifyChannel" }

    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_gateway_requests_30d:
          type: integer
          nullable: true
          description: |
            Requests this domain's gateway records may serve in a rolling 30-day
            window. Null means unlimited. See the gateway list endpoint for
            usage against it.
        max_cache_cap_mb: { type: integer, description: Ceiling for the domain's `cache_cap_mb` — the largest response body cached. }
        max_cache_disk_gb: { type: integer, description: Ceiling for the domain's `cache_l2_max_gb` — the size of its cache pool. }
        max_cache_ttl_days: { type: integer, description: Ceiling for the domain's `cache_l2_ttl_days` — how long a cached entry may live. }
        disabled_rule_types:
          type: array
          items: { type: string }
          description: |
            Rule types this plan may NOT create, by rule `type` string (for
            example `origin_pool`, `origin_route`, `optimize`). A DENY list: any
            type not listed is available. Rules of a blocked type that already
            exist keep working.
        gateways_enabled: { type: boolean, description: Gates the Gateways feature. }
        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. }
        bot_cache_enabled: { type: boolean, description: Gates the domain's bot cache (`bot_cache_enabled` on Domain). }
        email_routing_enabled: { type: boolean, description: Gates Email Routing (the `/email-routing` endpoints). }
        max_email_rules:
          type: integer
          nullable: true
          description: Custom email addresses this domain may hold. Null means unlimited.
        max_email_forwards_per_day:
          type: integer
          nullable: true
          description: |
            Messages this domain may forward per UTC day. Null means unlimited.
            Past it, the mail hosts defer (SMTP 452) until the day rolls over.
        custom_ssl_enabled: { type: boolean, description: Gates custom certificate upload. }
        ws_enabled: { type: boolean, description: WebSocket support. }
        host_header_edit_enabled: { type: boolean }
        uptime_sms_enabled:
          type: boolean
          description: |
            Whether uptime outage/recovery alerts may be delivered by SMS. When
            false they still go out by email and to the panel — uptime
            monitoring itself is not gated.
        dedicated_support_enabled: { type: boolean, description: Direct support when true; ticket support when false. }
        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 }
        quota_period_start: { type: string, format: date-time, description: Present on the per-domain endpoint while the domain has a live subscription. }
        quota_period_end: { type: string, format: date-time }
        quota_period_used_bytes: { type: integer, description: Bytes metered against the current quota period (billed ledger plus the unbilled tail). }
        quota_period_charged_rials:
          type: integer
          description: |
            What the current quota period has cost so far: the rials actually
            debited for billed windows, plus the not-yet-billed tail priced
            against the allowance that remains. Per-domain endpoint only.
        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
    # -------------------------------------------------------------------------

    TicketAttachmentUpload:
      type: object
      description: |
        The file part of a `multipart/form-data` ticket or reply. Send the files
        under `files` — `file`, `attachments`, `images` and `image` are accepted
        as aliases for older clients, and files sent under several of them are
        combined, not deduplicated.

        Accepted: JPEG, PNG, GIF and WebP images, and `.txt`, `.log` and `.json`
        text files. Caps are per kind — 3 MB per image, 1 MB per text file — and
        eight files per message across all kinds.

        Refused, and they will stay refused: `.html`, `.xml`, `.svg` (an image
        format that can execute script, which from an API origin is same-origin
        XSS) and every archive format, whose contents we cannot inspect. The
        extension must also agree with the bytes: an image is sniffed by magic
        number, text must be valid UTF-8 with no NUL bytes, and `.json` must
        parse. Double extensions such as `payload.html.txt` are rejected.
      properties:
        files:
          type: array
          description: Up to 8 files per message.
          items: { type: string, format: binary }
          maxItems: 8

    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 }
        edited_at:
          type: string
          format: date-time
          description: |
            When the message was last edited. Absent on a message that has never
            been edited — render the "edited" marker off this field, not off
            `updated_at`.
        edited_by_user_id:
          type: integer
          description: Who made the last edit. Absent on a message that has never been edited.
        edit_count:
          type: integer
          description: How many times the message has been edited. `0` for an untouched message.

    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 }
        closed_reason:
          type: string
          description: >-
            Why the ticket is closed: `staff` when a person closed it, `auto` when it closed
            itself after `ticket_auto_close_days` without a new message. Absent while open.
            Posting a message to an `auto` closed ticket reopens it; a `staff` close is final.
        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. }

    Notification:
      type: object
      properties:
        id: { type: integer }
        kind:
          type: string
          description: |
            The specific event, for example `domain_active`, `origin_outage`,
            `plan_traffic_high` or `invoice_issued`.
        category:
          type: string
          description: The group the kind belongs to.
          enum: [domain, uptime, ssl, plan, billing, ticket]
        domain_id:
          type: integer
          description: The domain this is about. Absent for account-wide notifications.
        domain_name:
          type: string
          description: Name of `domain_id`, resolved for convenience.
        subject: { type: string, description: Short title, in Persian. }
        message: { type: string, description: The notification body, in Persian. }
        is_unread: { type: boolean, description: You have not marked this seen yet. }
        created_at: { type: string, format: date-time }
